End to end encrypted attachment upload and streaming
This commit is contained in:
parent
575e9e2010
commit
64ad8498f5
74 changed files with 2368 additions and 151 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "pulsar-web",
|
"name": "pulsar-web",
|
||||||
"version": "0.5.2",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulsar-web",
|
"name": "pulsar-web",
|
||||||
"version": "0.5.2",
|
"version": "0.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@solidjs/router": "^0.15.4",
|
"@solidjs/router": "^0.15.4",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pulsar-web",
|
"name": "pulsar-web",
|
||||||
"version": "0.5.2",
|
"version": "0.6.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,10 @@ interface IFetchChannelMessage {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
|
replyToId?: string;
|
||||||
edited: boolean;
|
edited: boolean;
|
||||||
|
reactions: string[];
|
||||||
|
attachments: string[];
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
creationDate: number;
|
creationDate: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ interface IFetchCommunityMembersResponse extends IResponseSuccess {
|
||||||
interface IFetchCommunityMember {
|
interface IFetchCommunityMember {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
nickname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IFetchCommunityInvitesRequest {
|
interface IFetchCommunityInvitesRequest {
|
||||||
|
|
|
||||||
105
src/api/file/file.ts
Normal file
105
src/api/file/file.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { callApi, HTTP } from "../tools";
|
||||||
|
import { IResponseError } from "../types";
|
||||||
|
import {
|
||||||
|
IUploadUserAvatarRequest,
|
||||||
|
IUploadUserAvatarResponse,
|
||||||
|
IUploadCommunityAvatarRequest,
|
||||||
|
IUploadCommunityAvatarResponse,
|
||||||
|
IFetchAttachmentRequest,
|
||||||
|
IFetchAttachmentResponse,
|
||||||
|
ICreateAttachmentRequest,
|
||||||
|
ICreateAttachmentResponse,
|
||||||
|
IFinishAttachmentRequest,
|
||||||
|
IFinishAttachmentResponse,
|
||||||
|
IFetchChunkRequest,
|
||||||
|
IUploadChunkRequest,
|
||||||
|
IUploadChunkResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const uploadUserAvatarApi = async (
|
||||||
|
request: IUploadUserAvatarRequest,
|
||||||
|
): Promise<IUploadUserAvatarResponse | IResponseError> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", request.file);
|
||||||
|
formData.append("filename", request.filename);
|
||||||
|
|
||||||
|
return await callApi(
|
||||||
|
HTTP.POST,
|
||||||
|
`file/avatar/user/upload`,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadCommunityAvatarApi = async (
|
||||||
|
request: IUploadCommunityAvatarRequest,
|
||||||
|
): Promise<IUploadCommunityAvatarResponse | IResponseError> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", request.file);
|
||||||
|
formData.append("filename", request.filename);
|
||||||
|
formData.append("communityId", request.communityId);
|
||||||
|
|
||||||
|
return await callApi(
|
||||||
|
HTTP.POST,
|
||||||
|
`file/avatar/community/upload`,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAttachmentApi = async (
|
||||||
|
request: IFetchAttachmentRequest,
|
||||||
|
): Promise<IFetchAttachmentResponse | IResponseError> => {
|
||||||
|
return await callApi(HTTP.GET, `file/attachment/${request.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAttachmentApi = async (
|
||||||
|
request: ICreateAttachmentRequest,
|
||||||
|
): Promise<ICreateAttachmentResponse | IResponseError> => {
|
||||||
|
return await callApi(HTTP.POST, `file/attachment`, request);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishAttachmentApi = async (
|
||||||
|
request: IFinishAttachmentRequest,
|
||||||
|
): Promise<IFinishAttachmentResponse | IResponseError> => {
|
||||||
|
return await callApi(HTTP.GET, `file/attachment/${request.id}/finish`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChunkApi = async (
|
||||||
|
request: IFetchChunkRequest,
|
||||||
|
): Promise<Response | IResponseError> => {
|
||||||
|
return await callApi(
|
||||||
|
HTTP.GET_BINARY,
|
||||||
|
`file/attachment/${request.attachmentId}/chunk/${request.index}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadChunkApi = async (
|
||||||
|
request: IUploadChunkRequest,
|
||||||
|
): Promise<IUploadChunkResponse | IResponseError> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", request.file);
|
||||||
|
formData.append("iv", request.iv);
|
||||||
|
formData.append("index", request.index.toString());
|
||||||
|
formData.append("attachmentId", request.attachmentId);
|
||||||
|
|
||||||
|
return await callApi(
|
||||||
|
HTTP.POST,
|
||||||
|
`file/attachment/${request.attachmentId}/chunk/${request.index}`,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
uploadUserAvatarApi,
|
||||||
|
uploadCommunityAvatarApi,
|
||||||
|
fetchAttachmentApi,
|
||||||
|
createAttachmentApi,
|
||||||
|
finishAttachmentApi,
|
||||||
|
fetchChunkApi,
|
||||||
|
uploadChunkApi,
|
||||||
|
};
|
||||||
2
src/api/file/index.ts
Normal file
2
src/api/file/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./file";
|
||||||
|
export * from "./types";
|
||||||
90
src/api/file/types.ts
Normal file
90
src/api/file/types.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { IResponseSuccess } from "../types";
|
||||||
|
|
||||||
|
interface IUploadUserAvatarRequest {
|
||||||
|
filename: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUploadUserAvatarResponse extends IResponseSuccess {}
|
||||||
|
|
||||||
|
interface IUploadCommunityAvatarRequest {
|
||||||
|
communityId: string;
|
||||||
|
filename: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUploadCommunityAvatarResponse extends IResponseSuccess {}
|
||||||
|
|
||||||
|
interface IFetchAttachment {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
messageId: string;
|
||||||
|
chunks: string[];
|
||||||
|
finishedUploading: boolean;
|
||||||
|
creationDate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFetchAttachmentRequest {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFetchAttachmentResponse extends IResponseSuccess, IFetchAttachment {}
|
||||||
|
|
||||||
|
interface ICreateAttachmentRequest {
|
||||||
|
filename: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
communityId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICreateAttachmentResponse
|
||||||
|
extends IResponseSuccess,
|
||||||
|
IFetchAttachment {}
|
||||||
|
|
||||||
|
interface IFinishAttachmentRequest {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFinishAttachmentResponse extends IResponseSuccess {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFetchChunk {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
attachmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFetchChunkRequest {
|
||||||
|
attachmentId: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUploadChunkRequest {
|
||||||
|
attachmentId: string;
|
||||||
|
index: number;
|
||||||
|
iv: string;
|
||||||
|
file: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUploadChunkResponse extends IResponseSuccess, IFetchChunk {}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type IUploadUserAvatarRequest,
|
||||||
|
type IUploadUserAvatarResponse,
|
||||||
|
type IUploadCommunityAvatarRequest,
|
||||||
|
type IUploadCommunityAvatarResponse,
|
||||||
|
type IFetchAttachment,
|
||||||
|
type IFetchAttachmentRequest,
|
||||||
|
type IFetchAttachmentResponse,
|
||||||
|
type ICreateAttachmentRequest,
|
||||||
|
type ICreateAttachmentResponse,
|
||||||
|
type IFinishAttachmentRequest,
|
||||||
|
type IFinishAttachmentResponse,
|
||||||
|
type IFetchChunk,
|
||||||
|
type IFetchChunkRequest,
|
||||||
|
type IUploadChunkRequest,
|
||||||
|
type IUploadChunkResponse,
|
||||||
|
};
|
||||||
|
|
@ -4,8 +4,11 @@ interface IFetchMessage {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
editHistory: string[];
|
replyToId?: string;
|
||||||
edited: boolean;
|
edited: boolean;
|
||||||
|
editHistory: string[];
|
||||||
|
reactions: string[];
|
||||||
|
attachments: string[];
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
creationDate: number;
|
creationDate: number;
|
||||||
|
|
@ -21,6 +24,8 @@ interface ICreateMessageRequest {
|
||||||
text: string;
|
text: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
|
replyToId?: string;
|
||||||
|
attachments: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICreateMessageResponse extends IResponseSuccess, IFetchMessage {}
|
interface ICreateMessageResponse extends IResponseSuccess, IFetchMessage {}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import config from "./config.json";
|
||||||
|
|
||||||
enum HTTP {
|
enum HTTP {
|
||||||
GET,
|
GET,
|
||||||
|
GET_BINARY,
|
||||||
POST,
|
POST,
|
||||||
PATCH,
|
PATCH,
|
||||||
DELETE,
|
DELETE,
|
||||||
|
|
@ -13,6 +14,7 @@ async function callApi(
|
||||||
path: string,
|
path: string,
|
||||||
body?: object,
|
body?: object,
|
||||||
includeCookies?: boolean,
|
includeCookies?: boolean,
|
||||||
|
formData?: FormData,
|
||||||
) {
|
) {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
@ -29,20 +31,47 @@ async function callApi(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case HTTP.POST:
|
case HTTP.GET_BINARY:
|
||||||
response = await fetch(
|
return await fetch(
|
||||||
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
|
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "GET",
|
||||||
credentials: includeCookies ? "include" : "omit",
|
credentials: includeCookies ? "include" : "omit",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${state.auth.session?.token}`,
|
Authorization: `Bearer ${state.auth.session?.token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body ?? {}),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
break;
|
case HTTP.POST:
|
||||||
|
if (formData) {
|
||||||
|
response = await fetch(
|
||||||
|
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: includeCookies ? "include" : "omit",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${state.auth.session?.token}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
response = await fetch(
|
||||||
|
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: includeCookies ? "include" : "omit",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${state.auth.session?.token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case HTTP.PATCH:
|
case HTTP.PATCH:
|
||||||
response = await fetch(
|
response = await fetch(
|
||||||
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
|
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { IResponseSuccess } from "../types";
|
||||||
interface IFetchUser {
|
interface IFetchUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
nickname: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
|
|
@ -52,6 +53,15 @@ interface IFetchUserCommunity {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IUpdateUserRequest {
|
||||||
|
id: string;
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUpdateUserResponse extends IResponseSuccess, IFetchUser {}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type IFetchUser,
|
type IFetchUser,
|
||||||
type IFetchLoggedUserResponse,
|
type IFetchLoggedUserResponse,
|
||||||
|
|
@ -63,4 +73,6 @@ export {
|
||||||
type IFetchUserCommunitiesRequest,
|
type IFetchUserCommunitiesRequest,
|
||||||
type IFetchUserCommunitiesResponse,
|
type IFetchUserCommunitiesResponse,
|
||||||
type IFetchUserCommunity,
|
type IFetchUserCommunity,
|
||||||
|
type IUpdateUserRequest,
|
||||||
|
type IUpdateUserResponse,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
IFetchUserSessionsResponse,
|
IFetchUserSessionsResponse,
|
||||||
IFetchUserCommunitiesRequest,
|
IFetchUserCommunitiesRequest,
|
||||||
IFetchUserCommunitiesResponse,
|
IFetchUserCommunitiesResponse,
|
||||||
|
IUpdateUserRequest,
|
||||||
|
IUpdateUserResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const fetchLoggedUserApi = async (): Promise<
|
const fetchLoggedUserApi = async (): Promise<
|
||||||
|
|
@ -34,9 +36,16 @@ const fetchUserCommunitiesApi = async (
|
||||||
return await callApi(HTTP.GET, `user/${request.id}/communities`);
|
return await callApi(HTTP.GET, `user/${request.id}/communities`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateUserApi = async (
|
||||||
|
request: IUpdateUserRequest,
|
||||||
|
): Promise<IUpdateUserResponse | IResponseError> => {
|
||||||
|
return await callApi(HTTP.PATCH, `user/${request.id}`, request);
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
fetchLoggedUserApi,
|
fetchLoggedUserApi,
|
||||||
fetchUserApi,
|
fetchUserApi,
|
||||||
fetchUserSessionsApi,
|
fetchUserSessionsApi,
|
||||||
fetchUserCommunitiesApi,
|
fetchUserCommunitiesApi,
|
||||||
|
updateUserApi,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { IChannelBarProps } from "./types";
|
||||||
const ChannelBar: Component<IChannelBarProps> = (props: IChannelBarProps) => {
|
const ChannelBar: Component<IChannelBarProps> = (props: IChannelBarProps) => {
|
||||||
return (
|
return (
|
||||||
<div class="absolute w-full top-0 z-10">
|
<div class="absolute w-full top-0 z-10">
|
||||||
<div class="flex flex-col justify-center bg-stone-800/25 backdrop-blur-md h-16 w-full shadow-bar px-5">
|
<div class="flex flex-col justify-center bg-stone-900/25 backdrop-blur-md h-16 w-full shadow-bar px-5">
|
||||||
<h2 class="text-sm font-bold">
|
<h2 class="text-sm font-bold">
|
||||||
{props.name ? `# ${props.name}` : undefined}
|
{props.name ? `# ${props.name}` : undefined}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import { ICommunityProps } from "./types";
|
import { ICommunityProps } from "./types";
|
||||||
|
import { ServerIcon } from "../../icons";
|
||||||
|
|
||||||
const Community: Component<ICommunityProps> = (props: ICommunityProps) => {
|
const Community: Component<ICommunityProps> = (props: ICommunityProps) => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -10,7 +11,13 @@ const Community: Component<ICommunityProps> = (props: ICommunityProps) => {
|
||||||
<div
|
<div
|
||||||
class={`w-full transition-all duration-300 hover:outline-stone-300 ${props.active ? "rounded-lg outline-3 outline-stone-300 hover:outline-3" : "outline-transparent rounded-4xl outline-2"}`}
|
class={`w-full transition-all duration-300 hover:outline-stone-300 ${props.active ? "rounded-lg outline-3 outline-stone-300 hover:outline-3" : "outline-transparent rounded-4xl outline-2"}`}
|
||||||
>
|
>
|
||||||
<img src={props.avatar} />
|
{props.avatar ? (
|
||||||
|
<img src={props.avatar} />
|
||||||
|
) : (
|
||||||
|
<div class="bg-stone-800 p-2">
|
||||||
|
<ServerIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
interface ICommunityProps {
|
interface ICommunityProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar?: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onCommunityClick?: (id: string) => void;
|
onCommunityClick?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
src/components/FileInput/FileInput.tsx
Normal file
49
src/components/FileInput/FileInput.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { type Component, type JSXElement } from "solid-js";
|
||||||
|
import { IFileInputProps } from "./types";
|
||||||
|
import { UploadIcon, UploadMultiIcon } from "../../icons";
|
||||||
|
|
||||||
|
const FileInput: Component<IFileInputProps> = (props: IFileInputProps) => {
|
||||||
|
const iconOnlyHtml = (): JSXElement => (
|
||||||
|
<div class="w-full p-12">
|
||||||
|
{props.multifile ? <UploadMultiIcon /> : <UploadIcon />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const pictureHtml = (): JSXElement => (
|
||||||
|
<>
|
||||||
|
<div class="avatar w-full h-full">
|
||||||
|
<img
|
||||||
|
class={props.rounded ? "rounded-full" : "rounded-xl"}
|
||||||
|
src={props.picture}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={`absolute inset-0 flex items-center justify-center bg-black/50 text-white transition-opacity opacity-0 hover:opacity-100 ${props.rounded ? "rounded-full" : "rounded-xl"}`}
|
||||||
|
>
|
||||||
|
<div class="w-full p-12">
|
||||||
|
{props.multifile ? <UploadMultiIcon /> : <UploadIcon />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`bg-stone-800 h-40 w-40 p-2 ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class={`relative inline-block bg-stone-900 input w-full h-full p-0 focus:border-none outline-none cursor-pointer ${props.rounded ? "rounded-full" : "rounded-xl"}`}
|
||||||
|
>
|
||||||
|
{props.picture ? pictureHtml() : iconOnlyHtml()}
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
multiple={props.multifile}
|
||||||
|
onChange={(e) => props.onChange?.(e.currentTarget.files)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { FileInput };
|
||||||
2
src/components/FileInput/index.ts
Normal file
2
src/components/FileInput/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./FileInput";
|
||||||
|
export * from "./types";
|
||||||
9
src/components/FileInput/types.ts
Normal file
9
src/components/FileInput/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
interface IFileInputProps {
|
||||||
|
rounded?: boolean;
|
||||||
|
picture?: string;
|
||||||
|
outline?: boolean;
|
||||||
|
multifile?: boolean;
|
||||||
|
onChange?: (files: FileList | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type IFileInputProps };
|
||||||
47
src/components/FilePreview/FilePreview.tsx
Normal file
47
src/components/FilePreview/FilePreview.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { type Component } from "solid-js";
|
||||||
|
import { IFilePreviewProps } from "./types";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
|
||||||
|
const FilePreview: Component<IFilePreviewProps> = (
|
||||||
|
props: IFilePreviewProps,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`bg-stone-800 h-40 w-40 p-2 ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class={`relative inline-block bg-stone-900 input w-full h-full p-0 focus:border-none outline-none ${props.clickable ? "cursor-pointer" : "cursor-default"} ${props.rounded ? "rounded-full" : "rounded-xl"}`}
|
||||||
|
>
|
||||||
|
{props.picture ? (
|
||||||
|
<div class="avatar w-full h-full">
|
||||||
|
<img
|
||||||
|
class={
|
||||||
|
props.rounded ? "rounded-full" : "rounded-xl"
|
||||||
|
}
|
||||||
|
src={props.picture}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<div class="w-12">
|
||||||
|
<Dynamic component={props.icon} />
|
||||||
|
</div>
|
||||||
|
{props.filename}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{props.clickable ? (
|
||||||
|
<div
|
||||||
|
class={`absolute inset-0 flex items-center justify-center bg-black/50 text-white transition-opacity opacity-0 hover:opacity-100 ${props.rounded ? "rounded-full" : "rounded-xl"}`}
|
||||||
|
onClick={() => props.onClick?.(props.id)}
|
||||||
|
>
|
||||||
|
<div class="w-full p-12">
|
||||||
|
<Dynamic component={props.clickIcon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { FilePreview };
|
||||||
2
src/components/FilePreview/index.ts
Normal file
2
src/components/FilePreview/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./FilePreview";
|
||||||
|
export * from "./types";
|
||||||
16
src/components/FilePreview/types.ts
Normal file
16
src/components/FilePreview/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { JSXElement } from "solid-js";
|
||||||
|
import { IconParameters } from "../../icons";
|
||||||
|
|
||||||
|
interface IFilePreviewProps {
|
||||||
|
id: string | number;
|
||||||
|
icon?: (props: IconParameters) => JSXElement;
|
||||||
|
filename?: string;
|
||||||
|
rounded?: boolean;
|
||||||
|
picture?: string;
|
||||||
|
outline?: boolean;
|
||||||
|
clickable?: boolean;
|
||||||
|
clickIcon?: (props: IconParameters) => JSXElement;
|
||||||
|
onClick?: (id: string | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type IFilePreviewProps };
|
||||||
|
|
@ -17,20 +17,34 @@ const Input: Component<IInputProps> = (props: IInputProps) => {
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const inputHtml = (): JSXElement => (
|
||||||
|
<input
|
||||||
|
type={props.type ? props.type : "text"}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
value={props.value}
|
||||||
|
onInput={(e) => props.onChange?.(e.currentTarget.value)}
|
||||||
|
onKeyDown={handleEnter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const textAreaHtml = (): JSXElement => (
|
||||||
|
<textarea
|
||||||
|
class="w-full h-full outline-none hover:outline-none px-5 py-3 resize-none"
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
value={props.value}
|
||||||
|
onInput={(e) => props.onChange?.(e.currentTarget.value)}
|
||||||
|
onKeyDown={handleEnter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`bg-stone-800 h-16 p-2 flex flex-row gap-2 ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`}
|
class={`bg-stone-800 ${props.textArea ? "h-32" : "h-16"} p-2 flex flex-row gap-2 ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`}
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
class={`bg-stone-800 input px-5 w-full h-full focus:border-none outline-none ${props.rounded ? "rounded-full" : "rounded-xl"}`}
|
class={`bg-stone-800 input ${props.textArea ? "px-0" : "px-5"} w-full h-full focus:border-none outline-none ${props.rounded ? "rounded-full" : "rounded-xl"}`}
|
||||||
>
|
>
|
||||||
<input
|
{props.textArea ? textAreaHtml() : inputHtml()}
|
||||||
type={props.type ? props.type : "text"}
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
value={props.value}
|
|
||||||
onInput={(e) => props.onChange?.(e.currentTarget.value)}
|
|
||||||
onKeyDown={handleEnter}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
{props.submitText ? submitHtml() : undefined}
|
{props.submitText ? submitHtml() : undefined}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ interface IInputProps {
|
||||||
type?: "text" | "password" | "email";
|
type?: "text" | "password" | "email";
|
||||||
outline?: boolean;
|
outline?: boolean;
|
||||||
rounded?: boolean;
|
rounded?: boolean;
|
||||||
|
textArea?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
submitText?: string;
|
submitText?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
import { IMemberProps } from "./types";
|
import { IMemberProps } from "./types";
|
||||||
|
import { UserIcon } from "../../icons";
|
||||||
|
|
||||||
const Member: Component<IMemberProps> = (props: IMemberProps) => {
|
const Member: Component<IMemberProps> = (props: IMemberProps) => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -8,9 +9,15 @@ const Member: Component<IMemberProps> = (props: IMemberProps) => {
|
||||||
onClick={() => props.onMemberClick?.(props.id)}
|
onClick={() => props.onMemberClick?.(props.id)}
|
||||||
>
|
>
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<div class="w-9 rounded-full">
|
{props.avatar ? (
|
||||||
<img src={props.avatar} />
|
<div class="w-9 rounded-full">
|
||||||
</div>
|
<img src={props.avatar} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="w-9 rounded-full bg-stone-900 p-2">
|
||||||
|
<UserIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-bold">{props.username}</div>
|
<div class="font-bold">{props.username}</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
interface IMemberProps {
|
interface IMemberProps {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string;
|
avatar?: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onMemberClick?: (id: string) => void;
|
onMemberClick?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,134 @@
|
||||||
import type { Component } from "solid-js";
|
import type { Component, JSXElement } from "solid-js";
|
||||||
import { IMessageProps } from "./types";
|
import { IMessageProps } from "./types";
|
||||||
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
ErrorIcon,
|
||||||
|
FileIcon,
|
||||||
|
PictureIcon,
|
||||||
|
UserIcon,
|
||||||
|
ZoomIcon,
|
||||||
|
} from "../../icons";
|
||||||
|
import { state } from "../../store/state";
|
||||||
|
import { FilePreview } from "../FilePreview";
|
||||||
|
import { fetchFile } from "../../services/file";
|
||||||
|
import { getActiveCommunity } from "../../store/community";
|
||||||
|
|
||||||
const Message: Component<IMessageProps> = (props: IMessageProps) => {
|
const Message: Component<IMessageProps> = (props: IMessageProps) => {
|
||||||
return (
|
const avatarHtml = (): JSXElement => (
|
||||||
<li class="list-row p-3 hover:bg-stone-700">
|
<div
|
||||||
<div
|
class="avatar cursor-pointer"
|
||||||
class="avatar cursor-pointer"
|
onClick={() => props.onProfileClick?.(props.userId)}
|
||||||
onClick={() => props.onProfileClick?.(props.userId)}
|
>
|
||||||
>
|
{props.avatar ? (
|
||||||
<div class="w-10 rounded-full">
|
<div class="w-10 h-10 rounded-full">
|
||||||
<img src={props.avatar} />
|
<img src={props.avatar} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<div>
|
<div class="w-10 h-10 rounded-full bg-stone-900 p-2">
|
||||||
<div class="font-bold">{props.username}</div>
|
<UserIcon />
|
||||||
{props.decryptionStatus ? (
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameHtml = (): JSXElement => (
|
||||||
|
<div class="font-bold">{props.username}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDownloadAttachment = (attachmentId: string | number) => {
|
||||||
|
const attachment = state.file.attachments[attachmentId];
|
||||||
|
if (!attachment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const community = getActiveCommunity();
|
||||||
|
if (community) {
|
||||||
|
fetchFile(community.id, attachmentId.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenAttachment = (attachmentId: string | number) => {
|
||||||
|
const attachment = state.file.attachments[attachmentId];
|
||||||
|
if (!attachment?.fullFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(attachment.fullFile);
|
||||||
|
window.open(url, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapAttachment = (attachmentId: string): JSXElement => {
|
||||||
|
const attachment = state.file.attachments[attachmentId];
|
||||||
|
if (!attachment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.decryptionStatus === false) {
|
||||||
|
return (
|
||||||
|
<FilePreview
|
||||||
|
id={attachmentId}
|
||||||
|
icon={ErrorIcon}
|
||||||
|
filename="Decryption failed"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attachment.mimetype.startsWith("image/")) {
|
||||||
|
return (
|
||||||
|
<FilePreview
|
||||||
|
id={attachmentId}
|
||||||
|
icon={FileIcon}
|
||||||
|
filename={attachment.filename}
|
||||||
|
clickable={true}
|
||||||
|
clickIcon={DownloadIcon}
|
||||||
|
onClick={onDownloadAttachment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attachment.fullFile || !attachment.finishedDownloading) {
|
||||||
|
return (
|
||||||
|
<FilePreview
|
||||||
|
id={attachmentId}
|
||||||
|
icon={PictureIcon}
|
||||||
|
filename={attachment.filename}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilePreview
|
||||||
|
id={attachmentId}
|
||||||
|
picture={URL.createObjectURL(attachment.fullFile)}
|
||||||
|
clickable={true}
|
||||||
|
clickIcon={ZoomIcon}
|
||||||
|
onClick={onOpenAttachment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachmentsHtml = (): JSXElement => (
|
||||||
|
<div class="mt-2 flex flex-row flex-wrap gap-2">
|
||||||
|
{props.attachments.map(mapAttachment)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
class={`flex list-row hover:bg-stone-700 after:hidden ${props.plain ? "py-1" : "py-2 mt-3"} ${props.plain ? "" : "before:absolute before:top-0 before:left-2 before:right-2 before:h-px before:bg-stone-700 first:before:hidden"}`}
|
||||||
|
>
|
||||||
|
{props.plain ? undefined : avatarHtml()}
|
||||||
|
<div class={`pr-14 ${props.plain ? "pl-14" : ""}`}>
|
||||||
|
{props.plain ? undefined : nameHtml()}
|
||||||
|
{props.message.length ===
|
||||||
|
0 ? undefined : props.decryptionStatus ? (
|
||||||
<p class="list-col-wrap text-xs">{props.message}</p>
|
<p class="list-col-wrap text-xs">{props.message}</p>
|
||||||
) : (
|
) : (
|
||||||
<p class="list-col-wrap text-xs italic">
|
<p class="list-col-wrap text-xs italic opacity-75">
|
||||||
Decryption failed
|
Decryption failed
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{props.attachments.length > 0 ? attachmentsHtml() : undefined}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ interface IMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string;
|
avatar?: string;
|
||||||
|
attachments: string[];
|
||||||
|
plain: boolean;
|
||||||
decryptionStatus: boolean;
|
decryptionStatus: boolean;
|
||||||
onProfileClick?: (userId: string) => void;
|
onProfileClick?: (userId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,204 @@
|
||||||
import type { Component } from "solid-js";
|
import { createSignal, type Component, type JSXElement } from "solid-js";
|
||||||
import { IMessageBarProps } from "./types";
|
import { IMessageBarProps, MESSAGE_BAR_OPTIONS, OptionIcon } from "./types";
|
||||||
import { UpIcon } from "../../icons";
|
import {
|
||||||
|
AttachmentIcon,
|
||||||
|
PlusIcon,
|
||||||
|
PollIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UpIcon,
|
||||||
|
} from "../../icons";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import { FileInput } from "../FileInput";
|
||||||
|
import { FilePreview } from "../FilePreview";
|
||||||
|
|
||||||
const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
|
const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
|
||||||
|
const timeouts: number[] = [];
|
||||||
|
|
||||||
|
const [getDropdownOpen, setDropdownOpen] = createSignal<boolean>(false);
|
||||||
|
const [getAttachmentOpen, setAttachmentOpen] = createSignal<boolean>(false);
|
||||||
|
|
||||||
|
const [getFiles, setFiles] = createSignal<File[]>([]);
|
||||||
|
const [getFilePreviews, setFilePreviews] = createSignal<
|
||||||
|
(string | undefined)[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const options = new Map<MESSAGE_BAR_OPTIONS, OptionIcon>([
|
||||||
|
[MESSAGE_BAR_OPTIONS.ATTACH_FILES, AttachmentIcon],
|
||||||
|
[MESSAGE_BAR_OPTIONS.CREATE_POLL, PollIcon],
|
||||||
|
]);
|
||||||
|
|
||||||
const handleEnter = (e: KeyboardEvent) => {
|
const handleEnter = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
props.onSend?.();
|
onSend();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const openDropdown = () => {
|
||||||
<div class="absolute w-full bottom-0 p-4 z-10">
|
setDropdownOpen(true);
|
||||||
<div class="bg-stone-800/25 backdrop-blur-lg h-16 shadow-bar p-2 flex flex-row gap-2 rounded-full">
|
timeouts.push(setTimeout(() => setDropdownOpen(false), 5000));
|
||||||
<label class="bg-stone-800/50 backdrop-blur-lg input w-full h-full rounded-full focus:border-none outline-none">
|
};
|
||||||
<input
|
|
||||||
type="text"
|
const onOptionClick = (optionKey: MESSAGE_BAR_OPTIONS) => {
|
||||||
placeholder="Send a message..."
|
setDropdownOpen(false);
|
||||||
value={props.text}
|
timeouts.forEach((timeout) => clearTimeout(timeout));
|
||||||
onInput={(e) =>
|
|
||||||
props.onChangeText?.(e.currentTarget.value)
|
switch (optionKey) {
|
||||||
}
|
case MESSAGE_BAR_OPTIONS.ATTACH_FILES:
|
||||||
onKeyDown={handleEnter}
|
setAttachmentOpen(!getAttachmentOpen());
|
||||||
/>
|
break;
|
||||||
</label>
|
case MESSAGE_BAR_OPTIONS.CREATE_POLL:
|
||||||
<button
|
break;
|
||||||
class="bg-stone-950/50 backdrop-blur-lg btn btn-neutral w-12 p-0 h-full rounded-full"
|
}
|
||||||
onClick={props.onSend}
|
};
|
||||||
>
|
|
||||||
<div class="w-5">
|
const onFileAdd = (fileList: FileList | null) => {
|
||||||
<UpIcon />
|
if (!fileList) {
|
||||||
</div>
|
return;
|
||||||
</button>
|
}
|
||||||
|
|
||||||
|
let files: File[] = [...getFiles()];
|
||||||
|
let filePreviews: (string | undefined)[] = [...getFilePreviews()];
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const file = fileList.item(i);
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
files.push(file);
|
||||||
|
if (file.type.startsWith("image/")) {
|
||||||
|
filePreviews.push(URL.createObjectURL(file));
|
||||||
|
} else {
|
||||||
|
filePreviews.push(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles(files);
|
||||||
|
setFilePreviews(filePreviews);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileRemove = (id: string | number) => {
|
||||||
|
const index = id as number;
|
||||||
|
if (isNaN(index)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let files: File[] = [...getFiles()];
|
||||||
|
let filePreviews: (string | undefined)[] = [...getFilePreviews()];
|
||||||
|
|
||||||
|
files.splice(index, 1);
|
||||||
|
filePreviews.splice(index, 1);
|
||||||
|
|
||||||
|
setFiles(files);
|
||||||
|
setFilePreviews(filePreviews);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSend = () => {
|
||||||
|
props.onSend?.(getFiles());
|
||||||
|
|
||||||
|
setDropdownOpen(false);
|
||||||
|
setAttachmentOpen(false);
|
||||||
|
|
||||||
|
setFiles([]);
|
||||||
|
setFilePreviews([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapOption = (
|
||||||
|
option: [MESSAGE_BAR_OPTIONS, OptionIcon],
|
||||||
|
): JSXElement => (
|
||||||
|
<li class="bg-stone-800/25 backdrop-blur-lg gap-4 rounded-4xl w-fit p-2 shadow-bar">
|
||||||
|
<div
|
||||||
|
class="bg-stone-950/50 btn btn-neutral flex flex-row h-10 gap-2 px-3 justify-start w-fit rounded-full"
|
||||||
|
onClick={() => onOptionClick(option[0])}
|
||||||
|
>
|
||||||
|
<div class="w-5">
|
||||||
|
<Dynamic component={option[1]} />
|
||||||
|
</div>
|
||||||
|
{option[0]}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapFile = (
|
||||||
|
filePreview: string | undefined,
|
||||||
|
index: number,
|
||||||
|
): JSXElement => (
|
||||||
|
<div class="w-40">
|
||||||
|
<FilePreview
|
||||||
|
id={index}
|
||||||
|
icon={AttachmentIcon}
|
||||||
|
filename={getFiles().at(index)?.name}
|
||||||
|
clickable={true}
|
||||||
|
clickIcon={TrashIcon}
|
||||||
|
picture={filePreview}
|
||||||
|
onClick={onFileRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachmentHtml = (): JSXElement => (
|
||||||
|
<div class="absolute w-full bottom-18 p-4 z-10">
|
||||||
|
<div class="bg-stone-800/50 backdrop-blur-lg h-48 w-full shadow-bar rounded-3xl p-4 flex flex-row gap-2 overflow-x-scroll">
|
||||||
|
{getFilePreviews().map(mapFile)}
|
||||||
|
<div class="w-40">
|
||||||
|
<FileInput multifile={true} onChange={onFileAdd} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{getAttachmentOpen() ? attachmentHtml() : undefined}
|
||||||
|
<div class="absolute w-full bottom-0 p-4 z-10">
|
||||||
|
<div class="absolute h-16 w-full pr-8">
|
||||||
|
<div class="bg-stone-800/25 backdrop-blur-lg rounded-full h-full w-full"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-stone-800/25 h-16 shadow-bar p-2 flex flex-row gap-2 rounded-full">
|
||||||
|
<div
|
||||||
|
class={`group dropdown dropdown-top ${getDropdownOpen() ? "" : "dropdown-close"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
class={`btn btn-neutral bg-stone-950/50 backdrop-blur-lg w-12 p-0 h-full rounded-full transition-transform ${getDropdownOpen() ? "rotate-45" : ""}`}
|
||||||
|
onClick={openDropdown}
|
||||||
|
>
|
||||||
|
<div class="w-6">
|
||||||
|
<PlusIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
tabindex="-1"
|
||||||
|
class="dropdown-content -translate-x-2 -translate-y-4 z-10 w-64 animate-none"
|
||||||
|
>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{[...options].map(mapOption)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="bg-stone-900/50 backdrop-blur-lg input w-full h-full rounded-full focus:border-none outline-none">
|
||||||
|
<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 w-12 p-0 h-full rounded-full"
|
||||||
|
onClick={onSend}
|
||||||
|
>
|
||||||
|
<div class="w-5">
|
||||||
|
<UpIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { MessageBar };
|
export { MessageBar };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
|
import { JSXElement } from "solid-js";
|
||||||
|
import { IconParameters } from "../../icons";
|
||||||
|
|
||||||
interface IMessageBarProps {
|
interface IMessageBarProps {
|
||||||
text: string;
|
text: string;
|
||||||
onChangeText?: (text: string) => void;
|
onChangeText?: (text: string) => void;
|
||||||
onSend?: () => void;
|
onSend?: (files: File[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type IMessageBarProps };
|
type OptionIcon = (props: IconParameters) => JSXElement;
|
||||||
|
|
||||||
|
enum MESSAGE_BAR_OPTIONS {
|
||||||
|
ATTACH_FILES = "Attach files",
|
||||||
|
CREATE_POLL = "Create a poll",
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type IMessageBarProps, type OptionIcon, MESSAGE_BAR_OPTIONS };
|
||||||
|
|
|
||||||
34
src/icons/AttachmentIcon.tsx
Normal file
34
src/icons/AttachmentIcon.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconParameters,
|
||||||
|
defaultStrokeIconParameters as defaults,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const AttachmentIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AttachmentIcon;
|
||||||
31
src/icons/DownloadIcon.tsx
Normal file
31
src/icons/DownloadIcon.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
|
||||||
|
|
||||||
|
const DownloadIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.5 3.75a6 6 0 0 0-5.98 6.496A5.25 5.25 0 0 0 6.75 20.25H18a4.5 4.5 0 0 0 2.206-8.423 3.75 3.75 0 0 0-4.133-4.303A6.001 6.001 0 0 0 10.5 3.75Zm2.25 6a.75.75 0 0 0-1.5 0v4.94l-1.72-1.72a.75.75 0 0 0-1.06 1.06l3 3a.75.75 0 0 0 1.06 0l3-3a.75.75 0 1 0-1.06-1.06l-1.72 1.72V9.75Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadIcon;
|
||||||
31
src/icons/ErrorIcon.tsx
Normal file
31
src/icons/ErrorIcon.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
|
||||||
|
|
||||||
|
const ErrorIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorIcon;
|
||||||
28
src/icons/FileIcon.tsx
Normal file
28
src/icons/FileIcon.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
|
||||||
|
|
||||||
|
const FileIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625Z" />
|
||||||
|
<path d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileIcon;
|
||||||
31
src/icons/PictureIcon.tsx
Normal file
31
src/icons/PictureIcon.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
|
||||||
|
|
||||||
|
const PictureIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PictureIcon;
|
||||||
39
src/icons/PollIcon.tsx
Normal file
39
src/icons/PollIcon.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconParameters,
|
||||||
|
defaultStrokeIconParameters as defaults,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const PollIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M13.5 10.5H21A7.5 7.5 0 0 0 13.5 3v7.5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PollIcon;
|
||||||
32
src/icons/ServerIcon.tsx
Normal file
32
src/icons/ServerIcon.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
|
||||||
|
|
||||||
|
const ServerIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path d="M5.507 4.048A3 3 0 0 1 7.785 3h8.43a3 3 0 0 1 2.278 1.048l1.722 2.008A4.533 4.533 0 0 0 19.5 6h-15c-.243 0-.482.02-.715.056l1.722-2.008Z" />
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M1.5 10.5a3 3 0 0 1 3-3h15a3 3 0 1 1 0 6h-15a3 3 0 0 1-3-3Zm15 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm2.25.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM4.5 15a3 3 0 1 0 0 6h15a3 3 0 1 0 0-6h-15Zm11.25 3.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM19.5 18a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerIcon;
|
||||||
34
src/icons/UploadIcon.tsx
Normal file
34
src/icons/UploadIcon.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconParameters,
|
||||||
|
defaultStrokeIconParameters as defaults,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const UploadIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3V15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadIcon;
|
||||||
34
src/icons/UploadMultiIcon.tsx
Normal file
34
src/icons/UploadMultiIcon.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconParameters,
|
||||||
|
defaultStrokeIconParameters as defaults,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const UploadMultiIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v7.5a2.25 2.25 0 0 0 2.25 2.25h7.5a2.25 2.25 0 0 0 2.25-2.25v-7.5a2.25 2.25 0 0 0-2.25-2.25h-.75m0-3-3-3m0 0-3 3m3-3v11.25m6-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v7.5a2.25 2.25 0 0 1-2.25 2.25h-7.5a2.25 2.25 0 0 1-2.25-2.25v-.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadMultiIcon;
|
||||||
31
src/icons/UserIcon.tsx
Normal file
31
src/icons/UserIcon.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
|
||||||
|
|
||||||
|
const UserIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserIcon;
|
||||||
34
src/icons/ZoomIcon.tsx
Normal file
34
src/icons/ZoomIcon.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconParameters,
|
||||||
|
defaultStrokeIconParameters as defaults,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const ZoomIcon: Component<IconParameters> = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fill = defaults.fill,
|
||||||
|
stroke = defaults.stroke,
|
||||||
|
strokeWidth = defaults.strokeWidth,
|
||||||
|
}: IconParameters) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={fill}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width={strokeWidth}
|
||||||
|
stroke={stroke}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoomIcon;
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import HomeIcon from "./HomeIcon";
|
import HomeIcon from "./HomeIcon";
|
||||||
import SettingsIcon from "./SettingsIcon";
|
import SettingsIcon from "./SettingsIcon";
|
||||||
|
import ServerIcon from "./ServerIcon";
|
||||||
import PlusIcon from "./PlusIcon";
|
import PlusIcon from "./PlusIcon";
|
||||||
import MinusIcon from "./MinusIcon";
|
import MinusIcon from "./MinusIcon";
|
||||||
import DeviceIcon from "./DeviceIcon";
|
import DeviceIcon from "./DeviceIcon";
|
||||||
import TrashIcon from "./TrashIcon";
|
import TrashIcon from "./TrashIcon";
|
||||||
import UpIcon from "./UpIcon";
|
import UpIcon from "./UpIcon";
|
||||||
|
import UploadIcon from "./UploadIcon";
|
||||||
|
import UploadMultiIcon from "./UploadMultiIcon";
|
||||||
|
import AttachmentIcon from "./AttachmentIcon";
|
||||||
|
import PollIcon from "./PollIcon";
|
||||||
|
import UserIcon from "./UserIcon";
|
||||||
|
import FileIcon from "./FileIcon";
|
||||||
|
import DownloadIcon from "./DownloadIcon";
|
||||||
|
import ErrorIcon from "./ErrorIcon";
|
||||||
|
import ZoomIcon from "./ZoomIcon";
|
||||||
|
import PictureIcon from "./PictureIcon";
|
||||||
|
|
||||||
import type { IconParameters } from "./types";
|
import type { IconParameters } from "./types";
|
||||||
|
|
||||||
|
|
@ -12,9 +23,20 @@ export {
|
||||||
IconParameters,
|
IconParameters,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
ServerIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
DeviceIcon,
|
DeviceIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UpIcon,
|
UpIcon,
|
||||||
|
UploadIcon,
|
||||||
|
UploadMultiIcon,
|
||||||
|
AttachmentIcon,
|
||||||
|
PollIcon,
|
||||||
|
UserIcon,
|
||||||
|
FileIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
ErrorIcon,
|
||||||
|
ZoomIcon,
|
||||||
|
PictureIcon,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
setChannelMessages,
|
setChannelMessages,
|
||||||
} from "../../store/channel";
|
} from "../../store/channel";
|
||||||
import { IMessage } from "../../store/message";
|
import { IMessage } from "../../store/message";
|
||||||
|
import { fetchMedia } from "../file";
|
||||||
import { decryptMessage } from "../message";
|
import { decryptMessage } from "../message";
|
||||||
|
|
||||||
const fetchChannel = async (id: string) => {
|
const fetchChannel = async (id: string) => {
|
||||||
|
|
@ -106,6 +107,12 @@ const fetchChannelMessages = async (id: string, communityId: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
messages.forEach((message) => {
|
||||||
|
message.attachments.forEach((attachmentId) => {
|
||||||
|
fetchMedia(communityId, attachmentId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
setChannelMessages(data.id, messages);
|
setChannelMessages(data.id, messages);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import apiConfig from "../../api/config.json";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchCommunityApi,
|
fetchCommunityApi,
|
||||||
createCommunityApi,
|
createCommunityApi,
|
||||||
|
|
@ -7,6 +9,7 @@ import {
|
||||||
fetchCommunityRolesApi,
|
fetchCommunityRolesApi,
|
||||||
fetchCommunityMembersApi,
|
fetchCommunityMembersApi,
|
||||||
fetchCommunityInvitesApi,
|
fetchCommunityInvitesApi,
|
||||||
|
IUpdateCommunityRequest,
|
||||||
} from "../../api/community";
|
} from "../../api/community";
|
||||||
import { setChannel } from "../../store/channel";
|
import { setChannel } from "../../store/channel";
|
||||||
import {
|
import {
|
||||||
|
|
@ -54,15 +57,9 @@ const createCommunity = async (name: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCommunity = async (
|
const updateCommunity = async (
|
||||||
id: string,
|
updateCommunityData: IUpdateCommunityRequest,
|
||||||
name?: string,
|
|
||||||
description?: string,
|
|
||||||
) => {
|
) => {
|
||||||
const data = await updateCommunityApi({
|
const data = await updateCommunityApi(updateCommunityData);
|
||||||
id: id,
|
|
||||||
name: name,
|
|
||||||
description: description,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof data.error === "string") {
|
if (typeof data.error === "string") {
|
||||||
return;
|
return;
|
||||||
|
|
@ -176,6 +173,13 @@ const loadCommunityCryptoStates = async () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCommunityAvatarUrl = (avatar?: string): string | undefined => {
|
||||||
|
if (!avatar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return `${apiConfig.schema}://${apiConfig.url}:${apiConfig.port}/api/v1/file/avatar/community/${avatar}`;
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
fetchCommunity,
|
fetchCommunity,
|
||||||
createCommunity,
|
createCommunity,
|
||||||
|
|
@ -186,4 +190,5 @@ export {
|
||||||
fetchCommunityMembers,
|
fetchCommunityMembers,
|
||||||
fetchCommunityInvites,
|
fetchCommunityInvites,
|
||||||
loadCommunityCryptoStates,
|
loadCommunityCryptoStates,
|
||||||
|
getCommunityAvatarUrl,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,28 @@ const decryptData = async <T>(cryptoData: ICryptoEncrypted): Promise<T> => {
|
||||||
return decoded as T;
|
return decoded as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const encryptBytes = async (
|
||||||
|
cryptoData: ICryptoData<ArrayBuffer>,
|
||||||
|
): Promise<ArrayBuffer> => {
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv: cryptoData.iv },
|
||||||
|
cryptoData.key,
|
||||||
|
cryptoData.data,
|
||||||
|
);
|
||||||
|
|
||||||
|
return encrypted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptBytes = async (
|
||||||
|
cryptoData: ICryptoEncrypted,
|
||||||
|
): Promise<ArrayBuffer> => {
|
||||||
|
return await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: cryptoData.iv },
|
||||||
|
cryptoData.key,
|
||||||
|
cryptoData.encryptedData,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const generateIv = (): Uint8Array<ArrayBuffer> => {
|
const generateIv = (): Uint8Array<ArrayBuffer> => {
|
||||||
return crypto.getRandomValues(new Uint8Array(12));
|
return crypto.getRandomValues(new Uint8Array(12));
|
||||||
};
|
};
|
||||||
|
|
@ -84,12 +106,39 @@ const bytesToHex = (bytes: ArrayBuffer): string => {
|
||||||
return hex;
|
return hex;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bufferToBase64 = (buffer: ArrayBuffer): string => {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64ToBuffer = (base64: string): ArrayBuffer => {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const len = binary.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.buffer;
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
importKey,
|
importKey,
|
||||||
deriveKey,
|
deriveKey,
|
||||||
encryptData,
|
encryptData,
|
||||||
decryptData,
|
decryptData,
|
||||||
|
encryptBytes,
|
||||||
|
decryptBytes,
|
||||||
generateIv,
|
generateIv,
|
||||||
hexToBytes,
|
hexToBytes,
|
||||||
bytesToHex,
|
bytesToHex,
|
||||||
|
bufferToBase64,
|
||||||
|
base64ToBuffer,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -94,13 +94,17 @@ const dbLoadEncrypted = async <T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
const importedKey = await importKey(key);
|
const importedKey = await importKey(key);
|
||||||
const decrypted = await decryptData<T>({
|
try {
|
||||||
key: importedKey,
|
const decrypted = await decryptData<T>({
|
||||||
iv: iv,
|
key: importedKey,
|
||||||
encryptedData: item.data,
|
iv: iv,
|
||||||
});
|
encryptedData: item.data,
|
||||||
|
});
|
||||||
|
|
||||||
return decrypted;
|
return decrypted;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
341
src/services/file/file.ts
Normal file
341
src/services/file/file.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
import {
|
||||||
|
uploadCommunityAvatarApi,
|
||||||
|
uploadUserAvatarApi,
|
||||||
|
fetchAttachmentApi,
|
||||||
|
createAttachmentApi,
|
||||||
|
finishAttachmentApi,
|
||||||
|
fetchChunkApi,
|
||||||
|
uploadChunkApi,
|
||||||
|
} from "../../api/file";
|
||||||
|
import {
|
||||||
|
IAttachment,
|
||||||
|
setAttachment,
|
||||||
|
setAttachmentDecryptionStatus,
|
||||||
|
setAttachmentFinishedDownloading,
|
||||||
|
setAttachmentFinishedUploading,
|
||||||
|
setAttachmentFullFile,
|
||||||
|
setChunk,
|
||||||
|
} from "../../store/file";
|
||||||
|
import { state } from "../../store/state";
|
||||||
|
import {
|
||||||
|
bufferToBase64,
|
||||||
|
decryptBytes,
|
||||||
|
encryptBytes,
|
||||||
|
generateIv,
|
||||||
|
} from "../crypto";
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 1024 * 1024 * 5;
|
||||||
|
|
||||||
|
const uploadUserAvatar = async (filename: string, file: File) => {
|
||||||
|
const data = await uploadUserAvatarApi({ filename: filename, file: file });
|
||||||
|
|
||||||
|
if (typeof data.error === "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadCommunityAvatar = async (
|
||||||
|
communityId: string,
|
||||||
|
filename: string,
|
||||||
|
file: File,
|
||||||
|
) => {
|
||||||
|
const data = await uploadCommunityAvatarApi({
|
||||||
|
communityId: communityId,
|
||||||
|
filename: filename,
|
||||||
|
file: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof data.error === "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAttachment = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<IAttachment | undefined> => {
|
||||||
|
const data = await fetchAttachmentApi({
|
||||||
|
id: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof data.error === "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachment(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAttachment = async (
|
||||||
|
communityId: string,
|
||||||
|
filename: string,
|
||||||
|
mimetype: string,
|
||||||
|
size: number,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const data = await createAttachmentApi({
|
||||||
|
communityId: communityId,
|
||||||
|
filename: filename,
|
||||||
|
mimetype: mimetype,
|
||||||
|
size: size,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof data.error === "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachment({ ...data, fullFile: undefined });
|
||||||
|
|
||||||
|
return data.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishAttachment = async (id: string) => {
|
||||||
|
const data = await finishAttachmentApi({
|
||||||
|
id: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof data.error === "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachmentFinishedUploading(data.id, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChunk = async (
|
||||||
|
communityId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
index: number,
|
||||||
|
): Promise<[ArrayBuffer, boolean] | undefined> => {
|
||||||
|
const data = await fetchChunkApi({
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
index: index,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ("error" in data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await data.arrayBuffer();
|
||||||
|
const iv = buffer.slice(0, 12);
|
||||||
|
const file = buffer.slice(12);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decryptedFile = await decryptFile(communityId, file, iv);
|
||||||
|
if (!decryptedFile) {
|
||||||
|
return [file, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
setChunk({
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
index: index,
|
||||||
|
iv: iv,
|
||||||
|
file: decryptedFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [decryptedFile, true];
|
||||||
|
} catch {
|
||||||
|
return [file, false];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChunks = async (
|
||||||
|
communityId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
chunkCount: number,
|
||||||
|
mimetype?: string,
|
||||||
|
) => {
|
||||||
|
const file: ArrayBuffer[] = [];
|
||||||
|
|
||||||
|
let decryptionSuccessful = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < chunkCount; i++) {
|
||||||
|
const chunk = await fetchChunk(communityId, attachmentId, i);
|
||||||
|
if (chunk) {
|
||||||
|
const [buffer, status] = chunk;
|
||||||
|
file.push(buffer);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
decryptionSuccessful = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const blob = new Blob(file, {
|
||||||
|
type: mimetype ? mimetype : "application/octet-stream",
|
||||||
|
});
|
||||||
|
|
||||||
|
setAttachmentFullFile(attachmentId, blob);
|
||||||
|
setAttachmentFinishedDownloading(attachmentId, true);
|
||||||
|
setAttachmentDecryptionStatus(attachmentId, decryptionSuccessful);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFile = async (communityId: string, attachmentId: string) => {
|
||||||
|
const attachment = await fetchAttachment(attachmentId);
|
||||||
|
if (!attachment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchChunks(communityId, attachmentId, attachment.chunks.length);
|
||||||
|
|
||||||
|
const updatedAttachment = state.file.attachments[attachmentId];
|
||||||
|
if (updatedAttachment?.fullFile && updatedAttachment.decryptionStatus) {
|
||||||
|
const url = URL.createObjectURL(updatedAttachment.fullFile);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = attachment.filename;
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMedia = async (communityId: string, attachmentId: string) => {
|
||||||
|
const attachment = await fetchAttachment(attachmentId);
|
||||||
|
if (!attachment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.mimetype.startsWith("image/")) {
|
||||||
|
await fetchChunks(
|
||||||
|
communityId,
|
||||||
|
attachmentId,
|
||||||
|
attachment.chunks.length,
|
||||||
|
attachment.mimetype,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadChunk = async (
|
||||||
|
communityId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
index: number,
|
||||||
|
file: Blob,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const encrypted = await encryptFile(
|
||||||
|
communityId,
|
||||||
|
await file.arrayBuffer(),
|
||||||
|
);
|
||||||
|
if (!encrypted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [encryptedFile, iv] = encrypted;
|
||||||
|
const ivBase64 = bufferToBase64(iv);
|
||||||
|
|
||||||
|
const data = await uploadChunkApi({
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
index: index,
|
||||||
|
iv: ivBase64,
|
||||||
|
file: new Blob([encryptedFile]),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof data.error === "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChunk({
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
index: index,
|
||||||
|
iv: iv,
|
||||||
|
file: await file.arrayBuffer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadChunks = async (
|
||||||
|
communityId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||||
|
|
||||||
|
for (let index = 0; index < totalChunks; index++) {
|
||||||
|
const start = index * CHUNK_SIZE;
|
||||||
|
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||||
|
const blob = file.slice(start, end);
|
||||||
|
|
||||||
|
const uploaded = uploadChunk(communityId, attachmentId, index, blob);
|
||||||
|
if (!uploaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (
|
||||||
|
communityId: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const attachmentId = await createAttachment(
|
||||||
|
communityId,
|
||||||
|
file.name,
|
||||||
|
file.type,
|
||||||
|
file.size,
|
||||||
|
);
|
||||||
|
if (!attachmentId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploadChunks(communityId, attachmentId, file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptFile = async (
|
||||||
|
communityId: string,
|
||||||
|
file: ArrayBuffer,
|
||||||
|
): Promise<[ArrayBuffer, ArrayBuffer] | undefined> => {
|
||||||
|
const key = state.community.communities[communityId]?.derivedKey;
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = generateIv();
|
||||||
|
|
||||||
|
const encrypted = await encryptBytes({
|
||||||
|
key: key,
|
||||||
|
iv: iv,
|
||||||
|
data: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [encrypted, iv.buffer];
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptFile = async (
|
||||||
|
communityId: string,
|
||||||
|
file: ArrayBuffer,
|
||||||
|
iv: ArrayBuffer,
|
||||||
|
): Promise<ArrayBuffer | undefined> => {
|
||||||
|
const key = state.community.communities[communityId]?.derivedKey;
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await decryptBytes({
|
||||||
|
key: key,
|
||||||
|
iv: new Uint8Array(iv),
|
||||||
|
encryptedData: file,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
uploadUserAvatar,
|
||||||
|
uploadCommunityAvatar,
|
||||||
|
fetchAttachment,
|
||||||
|
createAttachment,
|
||||||
|
finishAttachment,
|
||||||
|
fetchChunk,
|
||||||
|
fetchChunks,
|
||||||
|
fetchFile,
|
||||||
|
fetchMedia,
|
||||||
|
uploadChunk,
|
||||||
|
uploadChunks,
|
||||||
|
uploadFile,
|
||||||
|
encryptFile,
|
||||||
|
decryptFile,
|
||||||
|
};
|
||||||
1
src/services/file/index.ts
Normal file
1
src/services/file/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./file";
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
updateMessageApi,
|
updateMessageApi,
|
||||||
removeMessageApi,
|
removeMessageApi,
|
||||||
} from "../../api/message";
|
} from "../../api/message";
|
||||||
|
import { deleteAttachment } from "../../store/file";
|
||||||
import { deleteMessage, setMessage } from "../../store/message";
|
import { deleteMessage, setMessage } from "../../store/message";
|
||||||
import { state } from "../../store/state";
|
import { state } from "../../store/state";
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +14,7 @@ import {
|
||||||
generateIv,
|
generateIv,
|
||||||
hexToBytes,
|
hexToBytes,
|
||||||
} from "../crypto";
|
} from "../crypto";
|
||||||
|
import { fetchMedia } from "../file";
|
||||||
|
|
||||||
const fetchMessage = async (id: string, communityId: string) => {
|
const fetchMessage = async (id: string, communityId: string) => {
|
||||||
const data = await fetchMessageApi({
|
const data = await fetchMessageApi({
|
||||||
|
|
@ -34,12 +36,18 @@ const fetchMessage = async (id: string, communityId: string) => {
|
||||||
text: decrypted,
|
text: decrypted,
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
data.attachments.forEach((attachmentId) => {
|
||||||
|
fetchMedia(communityId, attachmentId);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMessage = async (
|
const createMessage = async (
|
||||||
communityId: string,
|
communityId: string,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
text: string,
|
text: string,
|
||||||
|
attachments: string[],
|
||||||
|
replyToId?: string,
|
||||||
) => {
|
) => {
|
||||||
const encrypted = await encryptMessage(communityId, text);
|
const encrypted = await encryptMessage(communityId, text);
|
||||||
if (!encrypted) {
|
if (!encrypted) {
|
||||||
|
|
@ -48,9 +56,11 @@ const createMessage = async (
|
||||||
const [encryptedMessage, iv] = encrypted;
|
const [encryptedMessage, iv] = encrypted;
|
||||||
|
|
||||||
const data = await createMessageApi({
|
const data = await createMessageApi({
|
||||||
channelId: channelId,
|
|
||||||
iv: iv,
|
|
||||||
text: encryptedMessage,
|
text: encryptedMessage,
|
||||||
|
iv: iv,
|
||||||
|
channelId: channelId,
|
||||||
|
replyToId: replyToId,
|
||||||
|
attachments: attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (typeof data.error === "string") {
|
if (typeof data.error === "string") {
|
||||||
|
|
@ -61,6 +71,10 @@ const createMessage = async (
|
||||||
...data,
|
...data,
|
||||||
text: text,
|
text: text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
attachments.forEach((attachmentId) => {
|
||||||
|
deleteAttachment(attachmentId);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMessage = async (id: string, text: string) => {
|
const updateMessage = async (id: string, text: string) => {
|
||||||
|
|
@ -98,6 +112,11 @@ const encryptMessage = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const iv = generateIv();
|
const iv = generateIv();
|
||||||
|
|
||||||
|
if (text.length === 0) {
|
||||||
|
return ["", bytesToHex(iv.buffer)];
|
||||||
|
}
|
||||||
|
|
||||||
const encrypted = await encryptData<string>({
|
const encrypted = await encryptData<string>({
|
||||||
key: key,
|
key: key,
|
||||||
iv: iv,
|
iv: iv,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
import apiConfig from "../../api/config.json";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchLoggedUserApi,
|
fetchLoggedUserApi,
|
||||||
fetchUserApi,
|
fetchUserApi,
|
||||||
fetchUserSessionsApi,
|
fetchUserSessionsApi,
|
||||||
fetchUserCommunitiesApi,
|
fetchUserCommunitiesApi,
|
||||||
|
updateUserApi,
|
||||||
|
IUpdateUserRequest,
|
||||||
} from "../../api/user";
|
} from "../../api/user";
|
||||||
import { setCommunity } from "../../store/community";
|
import { setCommunity } from "../../store/community";
|
||||||
import { setSession } from "../../store/session";
|
import { setSession } from "../../store/session";
|
||||||
|
|
@ -67,4 +71,28 @@ const fetchUserCommunities = async (id: string) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export { fetchLoggedUser, fetchUser, fetchUserSessions, fetchUserCommunities };
|
const updateUser = async (updateUserData: IUpdateUserRequest) => {
|
||||||
|
const data = await updateUserApi(updateUserData);
|
||||||
|
|
||||||
|
if (typeof data.error === "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserAvatarUrl = (avatar?: string): string | undefined => {
|
||||||
|
if (!avatar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return `${apiConfig.schema}://${apiConfig.url}:${apiConfig.port}/api/v1/file/avatar/user/${avatar}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
fetchLoggedUser,
|
||||||
|
fetchUser,
|
||||||
|
fetchUserSessions,
|
||||||
|
fetchUserCommunities,
|
||||||
|
updateUser,
|
||||||
|
getUserAvatarUrl,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
fetchCommunityMembers,
|
fetchCommunityMembers,
|
||||||
fetchCommunityRoles,
|
fetchCommunityRoles,
|
||||||
} from "../community";
|
} from "../community";
|
||||||
|
import { fetchMedia } from "../file";
|
||||||
import { decryptMessage } from "../message";
|
import { decryptMessage } from "../message";
|
||||||
import config from "./config.json";
|
import config from "./config.json";
|
||||||
import { SocketMessage, SocketMessageTypes } from "./types";
|
import { SocketMessage, SocketMessageTypes } from "./types";
|
||||||
|
|
@ -45,18 +46,17 @@ const parseMessage = (data: string): SocketMessage | null => {
|
||||||
const handleMessage = (message: SocketMessage) => {
|
const handleMessage = (message: SocketMessage) => {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case SocketMessageTypes.SET_MESSAGE: {
|
case SocketMessageTypes.SET_MESSAGE: {
|
||||||
try {
|
const communityId =
|
||||||
const communityId =
|
state.channel.channels[message.payload.channelId]?.communityId;
|
||||||
state.channel.channels[message.payload.channelId]
|
if (!communityId) {
|
||||||
?.communityId;
|
break;
|
||||||
if (!communityId) {
|
}
|
||||||
break;
|
decryptMessage(
|
||||||
}
|
communityId,
|
||||||
decryptMessage(
|
message.payload.message.text,
|
||||||
communityId,
|
message.payload.message.iv,
|
||||||
message.payload.message.text,
|
)
|
||||||
message.payload.message.iv,
|
.then((decrypted) => {
|
||||||
).then((decrypted) => {
|
|
||||||
if (decrypted) {
|
if (decrypted) {
|
||||||
setChannelMessage(message.payload.channelId, {
|
setChannelMessage(message.payload.channelId, {
|
||||||
...message.payload.message,
|
...message.payload.message,
|
||||||
|
|
@ -69,13 +69,17 @@ const handleMessage = (message: SocketMessage) => {
|
||||||
decryptionStatus: false,
|
decryptionStatus: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setChannelMessage(message.payload.channelId, {
|
||||||
|
...message.payload.message,
|
||||||
|
decryptionStatus: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
setChannelMessage(message.payload.channelId, {
|
message.payload.message.attachments.forEach((attachmentId) => {
|
||||||
...message.payload.message,
|
fetchMedia(communityId, attachmentId);
|
||||||
decryptionStatus: false,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { IMessage } from "../message";
|
import { IMessage } from "../message";
|
||||||
import { setState } from "../state";
|
import { setState, state } from "../state";
|
||||||
import { IChannel } from "./types";
|
import { IChannel } from "./types";
|
||||||
|
|
||||||
|
const getActiveChannel = (): IChannel | undefined => {
|
||||||
|
if (!state.channel.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.channel.channels[state.channel.active];
|
||||||
|
};
|
||||||
|
|
||||||
const setChannel = (channel: IChannel) => {
|
const setChannel = (channel: IChannel) => {
|
||||||
setState("channel", "channels", channel.id, channel);
|
setState("channel", "channels", channel.id, channel);
|
||||||
};
|
};
|
||||||
|
|
@ -49,6 +57,7 @@ const deleteChannelMessage = (channelId: string, messageId: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
getActiveChannel,
|
||||||
setChannel,
|
setChannel,
|
||||||
deleteChannel,
|
deleteChannel,
|
||||||
setActiveChannel,
|
setActiveChannel,
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,61 @@ import { deriveKey } from "../../services/crypto";
|
||||||
import { IChannel } from "../channel";
|
import { IChannel } from "../channel";
|
||||||
import { IInvite } from "../invite";
|
import { IInvite } from "../invite";
|
||||||
import { IRole } from "../role";
|
import { IRole } from "../role";
|
||||||
import { setState } from "../state";
|
import { setState, state } from "../state";
|
||||||
import { IUser } from "../user";
|
import { IUser } from "../user";
|
||||||
import { ICommunity } from "./types";
|
import { ICommunity } from "./types";
|
||||||
|
|
||||||
|
const getActiveCommunity = (): ICommunity | undefined => {
|
||||||
|
if (!state.community.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.community.communities[state.community.active];
|
||||||
|
};
|
||||||
|
|
||||||
const setCommunity = (community: ICommunity) => {
|
const setCommunity = (community: ICommunity) => {
|
||||||
setState("community", "communities", community.id, community);
|
setState("community", "communities", community.id, community);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setCommunityAvatar = (communityId: string, avatar: string) => {
|
||||||
|
setState("community", "communities", communityId, "avatar", avatar);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveCommunityAvatar = (avatar: string) => {
|
||||||
|
const activeCommunity = getActiveCommunity();
|
||||||
|
if (activeCommunity) {
|
||||||
|
setCommunityAvatar(activeCommunity.id, avatar);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCommunityName = (communityId: string, name: string) => {
|
||||||
|
setState("community", "communities", communityId, "name", name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveCommunityName = (name: string) => {
|
||||||
|
const activeCommunity = getActiveCommunity();
|
||||||
|
if (activeCommunity) {
|
||||||
|
setCommunityName(activeCommunity.id, name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCommunityDescription = (communityId: string, description: string) => {
|
||||||
|
setState(
|
||||||
|
"community",
|
||||||
|
"communities",
|
||||||
|
communityId,
|
||||||
|
"description",
|
||||||
|
description,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveCommunityDescription = (description: string) => {
|
||||||
|
const activeCommunity = getActiveCommunity();
|
||||||
|
if (activeCommunity) {
|
||||||
|
setCommunityDescription(activeCommunity.id, description);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteCommunity = (communityId: string) => {
|
const deleteCommunity = (communityId: string) => {
|
||||||
setState("community", "communities", communityId, undefined);
|
setState("community", "communities", communityId, undefined);
|
||||||
};
|
};
|
||||||
|
|
@ -68,7 +115,14 @@ const setCommunityDerivedKey = (communityId: string, derivedKey: CryptoKey) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
getActiveCommunity,
|
||||||
setCommunity,
|
setCommunity,
|
||||||
|
setCommunityAvatar,
|
||||||
|
setActiveCommunityAvatar,
|
||||||
|
setCommunityName,
|
||||||
|
setActiveCommunityName,
|
||||||
|
setCommunityDescription,
|
||||||
|
setActiveCommunityDescription,
|
||||||
deleteCommunity,
|
deleteCommunity,
|
||||||
setActiveCommunity,
|
setActiveCommunity,
|
||||||
resetActiveCommunity,
|
resetActiveCommunity,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ interface ICommunity {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
avatar?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
creationDate?: number;
|
creationDate?: number;
|
||||||
channels?: string[];
|
channels?: string[];
|
||||||
|
|
|
||||||
84
src/store/file/file.ts
Normal file
84
src/store/file/file.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { setState, state } from "../state";
|
||||||
|
import { IAttachment, IChunk } from "./types";
|
||||||
|
|
||||||
|
const setAttachment = (attachment: IAttachment) => {
|
||||||
|
setState("file", "attachments", attachment.id, attachment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAttachmentFullFile = (attachmentId: string, fullFile: Blob) => {
|
||||||
|
setState("file", "attachments", attachmentId, "fullFile", fullFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAttachmentFinishedDownloading = (
|
||||||
|
attachmentId: string,
|
||||||
|
finishedDownloading: boolean,
|
||||||
|
) => {
|
||||||
|
setState(
|
||||||
|
"file",
|
||||||
|
"attachments",
|
||||||
|
attachmentId,
|
||||||
|
"finishedDownloading",
|
||||||
|
finishedDownloading,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAttachmentFinishedUploading = (
|
||||||
|
id: string,
|
||||||
|
finishedUploading: boolean,
|
||||||
|
) => {
|
||||||
|
const attachment = state.file.attachments[id];
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
setState(
|
||||||
|
"file",
|
||||||
|
"attachments",
|
||||||
|
id,
|
||||||
|
"finishedUploading",
|
||||||
|
finishedUploading,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAttachmentDecryptionStatus = (
|
||||||
|
id: string,
|
||||||
|
decryptionStatus: boolean,
|
||||||
|
) => {
|
||||||
|
const attachment = state.file.attachments[id];
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
setState(
|
||||||
|
"file",
|
||||||
|
"attachments",
|
||||||
|
id,
|
||||||
|
"decryptionStatus",
|
||||||
|
decryptionStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAttachment = (attachmentId: string) => {
|
||||||
|
setState("file", "attachments", attachmentId, undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChunk = (chunk: IChunk) => {
|
||||||
|
setState("file", "chunks", `${chunk.attachmentId};${chunk.index}`, chunk);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChunk = (attachmentId: string, index: number): IChunk | undefined => {
|
||||||
|
const attachment = state.file.attachments[attachmentId];
|
||||||
|
|
||||||
|
if (attachment) {
|
||||||
|
return state.file.chunks[`${attachmentId};${index}`];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
setAttachment,
|
||||||
|
setAttachmentFullFile,
|
||||||
|
setAttachmentFinishedDownloading,
|
||||||
|
setAttachmentFinishedUploading,
|
||||||
|
setAttachmentDecryptionStatus,
|
||||||
|
deleteAttachment,
|
||||||
|
setChunk,
|
||||||
|
getChunk,
|
||||||
|
};
|
||||||
2
src/store/file/index.ts
Normal file
2
src/store/file/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./file";
|
||||||
|
export * from "./types";
|
||||||
27
src/store/file/types.ts
Normal file
27
src/store/file/types.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
interface IFileState {
|
||||||
|
attachments: Record<string, IAttachment | undefined>;
|
||||||
|
chunks: Record<string, IChunk>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IAttachment {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
messageId: string;
|
||||||
|
chunks: string[];
|
||||||
|
finishedUploading: boolean;
|
||||||
|
creationDate: number;
|
||||||
|
fullFile?: Blob;
|
||||||
|
finishedDownloading?: boolean;
|
||||||
|
decryptionStatus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IChunk {
|
||||||
|
attachmentId: string;
|
||||||
|
index: number;
|
||||||
|
iv: ArrayBuffer;
|
||||||
|
file: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type IFileState, type IAttachment, type IChunk };
|
||||||
|
|
@ -6,8 +6,11 @@ interface IMessage {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
editHistory?: string[];
|
replyToId?: string;
|
||||||
edited: boolean;
|
edited: boolean;
|
||||||
|
editHistory?: string[];
|
||||||
|
reactions: string[];
|
||||||
|
attachments: string[];
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
creationDate: number;
|
creationDate: number;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ const [state, setState] = createStore<IState>({
|
||||||
message: {
|
message: {
|
||||||
message: undefined,
|
message: undefined,
|
||||||
},
|
},
|
||||||
|
file: {
|
||||||
|
attachments: {},
|
||||||
|
chunks: {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export { state, setState };
|
export { state, setState };
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { IRoleState } from "./role";
|
||||||
import { ISessionState } from "./session";
|
import { ISessionState } from "./session";
|
||||||
import { IInviteState } from "./invite";
|
import { IInviteState } from "./invite";
|
||||||
import { IMessageState } from "./message";
|
import { IMessageState } from "./message";
|
||||||
|
import { IFileState } from "./file";
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
app: IAppState;
|
app: IAppState;
|
||||||
|
|
@ -18,6 +19,7 @@ interface IState {
|
||||||
session: ISessionState;
|
session: ISessionState;
|
||||||
invite: IInviteState;
|
invite: IInviteState;
|
||||||
message: IMessageState;
|
message: IMessageState;
|
||||||
|
file: IFileState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type IState };
|
export { type IState };
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ interface IUserState {
|
||||||
interface IUser {
|
interface IUser {
|
||||||
id: string;
|
id: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
nickname?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
avatar?: string;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
registerDate?: number;
|
registerDate?: number;
|
||||||
lastLogin?: number;
|
lastLogin?: number;
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,65 @@ import {
|
||||||
IFetchUserSessionsResponse,
|
IFetchUserSessionsResponse,
|
||||||
} from "../../api/user";
|
} from "../../api/user";
|
||||||
import { loadCommunityCryptoStates } from "../../services/community";
|
import { loadCommunityCryptoStates } from "../../services/community";
|
||||||
import { setState } from "../state";
|
import { setState, state } from "../state";
|
||||||
import { IUser } from "./types";
|
import { IUser } from "./types";
|
||||||
|
|
||||||
|
const getLoggedInUser = (): IUser | undefined => {
|
||||||
|
if (!state.user.loggedUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.user.users[state.user.loggedUserId];
|
||||||
|
};
|
||||||
|
|
||||||
const setUser = (user: IUser) => {
|
const setUser = (user: IUser) => {
|
||||||
setState("user", "users", user.id, user);
|
setState("user", "users", user.id, user);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setUserAvatar = (userId: string, avatar: string) => {
|
||||||
|
setState("user", "users", userId, "avatar", avatar);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLoggedInUserAvatar = (avatar: string) => {
|
||||||
|
const loggedInUser = getLoggedInUser();
|
||||||
|
if (loggedInUser) {
|
||||||
|
setUserAvatar(loggedInUser.id, avatar);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUserNickname = (userId: string, nickname: string) => {
|
||||||
|
setState("user", "users", userId, "nickname", nickname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLoggedInUserNickname = (nickname: string) => {
|
||||||
|
const loggedInUser = getLoggedInUser();
|
||||||
|
if (loggedInUser) {
|
||||||
|
setUserNickname(loggedInUser.id, nickname);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUserEmail = (userId: string, email: string) => {
|
||||||
|
setState("user", "users", userId, "email", email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLoggedInUserEmail = (email: string) => {
|
||||||
|
const loggedInUser = getLoggedInUser();
|
||||||
|
if (loggedInUser) {
|
||||||
|
setUserEmail(loggedInUser.id, email);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUserDescription = (userId: string, description: string) => {
|
||||||
|
setState("user", "users", userId, "description", description);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLoggedInUserDescription = (description: string) => {
|
||||||
|
const loggedInUser = getLoggedInUser();
|
||||||
|
if (loggedInUser) {
|
||||||
|
setUserDescription(loggedInUser.id, description);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setLoggedUserId = (userId: string) => {
|
const setLoggedUserId = (userId: string) => {
|
||||||
setState("user", "loggedUserId", userId);
|
setState("user", "loggedUserId", userId);
|
||||||
};
|
};
|
||||||
|
|
@ -28,4 +80,18 @@ const setUserCommunities = (communities: IFetchUserCommunitiesResponse) => {
|
||||||
loadCommunityCryptoStates();
|
loadCommunityCryptoStates();
|
||||||
};
|
};
|
||||||
|
|
||||||
export { setUser, setLoggedUserId, setUserSessions, setUserCommunities };
|
export {
|
||||||
|
getLoggedInUser,
|
||||||
|
setUser,
|
||||||
|
setUserAvatar,
|
||||||
|
setLoggedInUserAvatar,
|
||||||
|
setUserNickname,
|
||||||
|
setLoggedInUserNickname,
|
||||||
|
setUserEmail,
|
||||||
|
setLoggedInUserEmail,
|
||||||
|
setUserDescription,
|
||||||
|
setLoggedInUserDescription,
|
||||||
|
setLoggedUserId,
|
||||||
|
setUserSessions,
|
||||||
|
setUserCommunities,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import { ModalView } from "../ModalView";
|
||||||
import { useNavigate } from "@solidjs/router";
|
import { useNavigate } from "@solidjs/router";
|
||||||
import { connectWs } from "../../services/websocket";
|
import { connectWs } from "../../services/websocket";
|
||||||
import { fetchRefresh } from "../../services/auth";
|
import { fetchRefresh } from "../../services/auth";
|
||||||
import { fetchLoggedUser, fetchUserCommunities } from "../../services/user";
|
import {
|
||||||
|
fetchLoggedUser,
|
||||||
|
fetchUser,
|
||||||
|
fetchUserCommunities,
|
||||||
|
} from "../../services/user";
|
||||||
|
|
||||||
const AppView: Component = () => {
|
const AppView: Component = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -26,6 +30,7 @@ const AppView: Component = () => {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (state.user.loggedUserId) {
|
if (state.user.loggedUserId) {
|
||||||
|
fetchUser(state.user.loggedUserId);
|
||||||
fetchUserCommunities(state.user.loggedUserId);
|
fetchUserCommunities(state.user.loggedUserId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,15 @@ import { Message } from "../../components/Message";
|
||||||
import { state } from "../../store/state";
|
import { state } from "../../store/state";
|
||||||
import { IChannel, setText } from "../../store/channel";
|
import { IChannel, setText } from "../../store/channel";
|
||||||
import { fetchChannelMessages } from "../../services/channel";
|
import { fetchChannelMessages } from "../../services/channel";
|
||||||
import { fetchUser } from "../../services/user";
|
import { fetchUser, getUserAvatarUrl } from "../../services/user";
|
||||||
import { createMessage } from "../../services/message";
|
import { createMessage } from "../../services/message";
|
||||||
import { IMessage } from "../../store/message";
|
import { IMessage } from "../../store/message";
|
||||||
|
import {
|
||||||
|
createAttachment,
|
||||||
|
finishAttachment,
|
||||||
|
uploadChunks,
|
||||||
|
} from "../../services/file";
|
||||||
|
import { getActiveCommunity } from "../../store/community";
|
||||||
|
|
||||||
const ChatView: Component = () => {
|
const ChatView: Component = () => {
|
||||||
let scrollRef: HTMLUListElement | undefined;
|
let scrollRef: HTMLUListElement | undefined;
|
||||||
|
|
@ -99,21 +105,67 @@ const ChatView: Component = () => {
|
||||||
setText(channel.id, text);
|
setText(channel.id, text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMessageSend = () => {
|
const onMessageSend = async (files: File[]) => {
|
||||||
autoScroll = true;
|
autoScroll = true;
|
||||||
|
|
||||||
const channel = channelInfo();
|
const channel = channelInfo();
|
||||||
if (!channel?.id || !state.community.active) {
|
const community = getActiveCommunity();
|
||||||
|
if (!channel?.id || !community?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = channel.text;
|
const text = channel.text;
|
||||||
if (!text || text.trim().length < 1) {
|
if (!text || text.trim().length < 1) {
|
||||||
return;
|
if (files.length < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentIds: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const attachmentId = await createAttachment(
|
||||||
|
community.id,
|
||||||
|
file.name,
|
||||||
|
file.type,
|
||||||
|
file.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attachmentId) {
|
||||||
|
attachmentIds.push(attachmentId);
|
||||||
|
const uploaded = await uploadChunks(
|
||||||
|
community.id,
|
||||||
|
attachmentId,
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
if (uploaded) {
|
||||||
|
await finishAttachment(attachmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setText(channel.id, "");
|
setText(channel.id, "");
|
||||||
createMessage(state.community.active, channel.id, text);
|
createMessage(
|
||||||
|
community.id,
|
||||||
|
channel.id,
|
||||||
|
text ?? "",
|
||||||
|
attachmentIds,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPlainMessage = (
|
||||||
|
message: IMessage,
|
||||||
|
previousMessage: IMessage | undefined,
|
||||||
|
) => {
|
||||||
|
if (!previousMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.ownerId !== previousMessage.ownerId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.creationDate < previousMessage.creationDate + 600000;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -129,18 +181,22 @@ const ChatView: Component = () => {
|
||||||
onScrollEnd={handleScroll}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{messages().map((message) => (
|
{messages().map((message, messageIndex, allMessages) => (
|
||||||
<Message
|
<Message
|
||||||
messageId={message?.id ?? ""}
|
messageId={message.id ?? ""}
|
||||||
message={message?.text ?? ""}
|
message={message.text}
|
||||||
userId={message?.ownerId ?? ""}
|
userId={message.ownerId}
|
||||||
username={
|
username={
|
||||||
state.user.users[message?.ownerId ?? ""]
|
state.user.users[message.ownerId].username ?? ""
|
||||||
.username ?? ""
|
|
||||||
}
|
|
||||||
avatar={
|
|
||||||
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
|
|
||||||
}
|
}
|
||||||
|
avatar={getUserAvatarUrl(
|
||||||
|
state.user.users[message.ownerId].avatar,
|
||||||
|
)}
|
||||||
|
attachments={message.attachments}
|
||||||
|
plain={isPlainMessage(
|
||||||
|
message,
|
||||||
|
allMessages[messageIndex - 1],
|
||||||
|
)}
|
||||||
decryptionStatus={message.decryptionStatus ?? false}
|
decryptionStatus={message.decryptionStatus ?? false}
|
||||||
onProfileClick={onProfileClick}
|
onProfileClick={onProfileClick}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,61 @@
|
||||||
import type { Component } from "solid-js";
|
import { createSignal, type Component, type JSXElement } from "solid-js";
|
||||||
import { ICommunitySettingsModalViewProps } from "./types";
|
import { ICommunitySettingsModalViewProps } from "./types";
|
||||||
|
import CommunitySettingsProfilePage from "./pages/SettingsProfilePage/CommunitySettingsProfilePage";
|
||||||
|
import { Dynamic } from "solid-js/web";
|
||||||
|
import { SettingsItem } from "../../components/SettingsItem";
|
||||||
|
import { getActiveCommunity } from "../../store/community";
|
||||||
|
|
||||||
const CommunitySettingsModalView: Component<
|
const CommunitySettingsModalView: Component<
|
||||||
ICommunitySettingsModalViewProps
|
ICommunitySettingsModalViewProps
|
||||||
> = (props: ICommunitySettingsModalViewProps) => {
|
> = (props: ICommunitySettingsModalViewProps) => {
|
||||||
|
const [getSelectedPage, setSelectedPage] = createSignal<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pages = new Map<string, Component>([
|
||||||
|
["Community Profile", CommunitySettingsProfilePage],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getCurrentPage = (): JSXElement => {
|
||||||
|
const selectedPage = getSelectedPage();
|
||||||
|
|
||||||
|
if (selectedPage) {
|
||||||
|
return <Dynamic component={pages.get(selectedPage)} />;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapPageButton = (page: [string, Component]): JSXElement => (
|
||||||
|
<SettingsItem
|
||||||
|
id={page[0]}
|
||||||
|
text={page[0]}
|
||||||
|
active={page[0] === getSelectedPage()}
|
||||||
|
onClick={setSelectedPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<dialog
|
<dialog
|
||||||
ref={props.dialogRef}
|
ref={props.dialogRef}
|
||||||
class="modal outline-none bg-[#00000050]"
|
class="modal outline-none bg-[#00000050]"
|
||||||
>
|
>
|
||||||
<div class="modal-box bg-stone-950 rounded-3xl">
|
<div class="modal-box w-10/12 max-w-5xl h-11/12 2xl:h-9/12 bg-stone-950 rounded-3xl">
|
||||||
<h3 class="text-lg font-bold text-center">
|
<h3 class="text-lg font-bold text-center">
|
||||||
Community Settings
|
{getActiveCommunity()?.name} Settings
|
||||||
</h3>
|
</h3>
|
||||||
<p class="py-4 text-center">Not implemented yet</p>
|
<div class="divider"></div>
|
||||||
|
<div class="flex flex-row gap-4 px-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-lg font-bold text-center mb-4">
|
||||||
|
Categories
|
||||||
|
</h3>
|
||||||
|
{[...pages].map(mapPageButton)}
|
||||||
|
</div>
|
||||||
|
<div class="divider divider-horizontal"></div>
|
||||||
|
{getCurrentPage()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { Component, createEffect, createSignal, onMount } from "solid-js";
|
||||||
|
import { Input } from "../../../../components/Input";
|
||||||
|
import { state } from "../../../../store/state";
|
||||||
|
import { FileInput } from "../../../../components/FileInput";
|
||||||
|
import { uploadCommunityAvatar } from "../../../../services/file";
|
||||||
|
import {
|
||||||
|
getActiveCommunity,
|
||||||
|
setActiveCommunityAvatar,
|
||||||
|
setActiveCommunityDescription,
|
||||||
|
setActiveCommunityName,
|
||||||
|
} from "../../../../store/community";
|
||||||
|
import {
|
||||||
|
fetchCommunity,
|
||||||
|
getCommunityAvatarUrl,
|
||||||
|
removeCommunity,
|
||||||
|
updateCommunity,
|
||||||
|
} from "../../../../services/community";
|
||||||
|
import { setCommunitySettingsOpen } from "../../../../store/app";
|
||||||
|
|
||||||
|
const CommunitySettingsProfilePage: Component = () => {
|
||||||
|
const [getSettingsName, setSettingsName] = createSignal<string>("");
|
||||||
|
const [getSettingsDescription, setSettingsDescription] =
|
||||||
|
createSignal<string>("");
|
||||||
|
const [getSettingsAvatar, setSettingsAvatar] = createSignal<
|
||||||
|
File | undefined
|
||||||
|
>();
|
||||||
|
const [getSettingsAvatarPreview, setSettingsAvatarPreview] = createSignal<
|
||||||
|
string | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (state.community.active) {
|
||||||
|
fetchCommunity(state.community.active);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const community = getActiveCommunity();
|
||||||
|
setSettingsName(community?.name ?? "");
|
||||||
|
setSettingsDescription(community?.description ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
const setAvatar = (files: FileList | null) => {
|
||||||
|
if (!files) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSettingsAvatar(files[0]);
|
||||||
|
if (files[0]) {
|
||||||
|
setSettingsAvatarPreview(URL.createObjectURL(files[0]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUploadAvatar = async (communityId: string) => {
|
||||||
|
const avatar = getSettingsAvatar();
|
||||||
|
if (avatar) {
|
||||||
|
await uploadCommunityAvatar(communityId, avatar.name, avatar);
|
||||||
|
setActiveCommunityAvatar(avatar.name);
|
||||||
|
setSettingsAvatar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSaveProfile = () => {
|
||||||
|
const activeCommunity = getActiveCommunity();
|
||||||
|
if (!activeCommunity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = getSettingsName().trim();
|
||||||
|
const description = getSettingsDescription().trim();
|
||||||
|
|
||||||
|
if (name.length > 0) {
|
||||||
|
setActiveCommunityName(name);
|
||||||
|
}
|
||||||
|
setActiveCommunityDescription(description);
|
||||||
|
|
||||||
|
updateCommunity({
|
||||||
|
id: activeCommunity.id,
|
||||||
|
name: activeCommunity.name,
|
||||||
|
description: activeCommunity.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
onUploadAvatar(activeCommunity.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteCommunity = () => {
|
||||||
|
const activeCommunity = getActiveCommunity();
|
||||||
|
if (!activeCommunity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCommunity(activeCommunity.id);
|
||||||
|
|
||||||
|
setCommunitySettingsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex-1 flex flex-col gap-0 w-full">
|
||||||
|
<h3 class="text-lg font-bold text-center mb-5">
|
||||||
|
{getActiveCommunity()?.name} Profile
|
||||||
|
</h3>
|
||||||
|
<h4 class="text-sm font-semibold mb-2 text-center">
|
||||||
|
Profile picture
|
||||||
|
</h4>
|
||||||
|
<div class="self-center">
|
||||||
|
<FileInput
|
||||||
|
rounded={true}
|
||||||
|
picture={
|
||||||
|
getSettingsAvatarPreview() ??
|
||||||
|
getCommunityAvatarUrl(getActiveCommunity()?.avatar)
|
||||||
|
}
|
||||||
|
outline={getSettingsAvatar() !== undefined}
|
||||||
|
onChange={setAvatar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">Name</h4>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter name"
|
||||||
|
outline={getActiveCommunity()?.name !== getSettingsName()}
|
||||||
|
value={getSettingsName()}
|
||||||
|
onChange={setSettingsName}
|
||||||
|
/>
|
||||||
|
<div class="py-3"></div>
|
||||||
|
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">
|
||||||
|
Description
|
||||||
|
</h4>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Describe your community"
|
||||||
|
outline={
|
||||||
|
(getActiveCommunity()?.description ?? "") !==
|
||||||
|
getSettingsDescription()
|
||||||
|
}
|
||||||
|
textArea={true}
|
||||||
|
value={getSettingsDescription()}
|
||||||
|
onChange={setSettingsDescription}
|
||||||
|
/>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button
|
||||||
|
class="bg-stone-950 btn rounded-xl h-14"
|
||||||
|
onClick={onSaveProfile}
|
||||||
|
>
|
||||||
|
Save Profile
|
||||||
|
</button>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button
|
||||||
|
class="bg-stone-950 text-error btn btn-error rounded-xl h-14"
|
||||||
|
onClick={onDeleteCommunity}
|
||||||
|
>
|
||||||
|
Delete Community
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommunitySettingsProfilePage;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./CommunitySettingsProfilePage";
|
||||||
|
|
@ -12,7 +12,10 @@ import {
|
||||||
resetActiveCommunity,
|
resetActiveCommunity,
|
||||||
setActiveCommunity,
|
setActiveCommunity,
|
||||||
} from "../../store/community";
|
} from "../../store/community";
|
||||||
import { fetchCommunity } from "../../services/community";
|
import {
|
||||||
|
fetchCommunity,
|
||||||
|
getCommunityAvatarUrl,
|
||||||
|
} from "../../services/community";
|
||||||
import { resetActiveChannel } from "../../store/channel";
|
import { resetActiveChannel } from "../../store/channel";
|
||||||
|
|
||||||
const CommunityView: Component = () => {
|
const CommunityView: Component = () => {
|
||||||
|
|
@ -63,9 +66,7 @@ const CommunityView: Component = () => {
|
||||||
<Community
|
<Community
|
||||||
id={community.id}
|
id={community.id}
|
||||||
name={community.name ?? ""}
|
name={community.name ?? ""}
|
||||||
avatar={
|
avatar={getCommunityAvatarUrl(community.avatar)}
|
||||||
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
|
|
||||||
}
|
|
||||||
active={community.id === state.community.active}
|
active={community.id === state.community.active}
|
||||||
onCommunityClick={onCommunityClick}
|
onCommunityClick={onCommunityClick}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { createEffect, createMemo, type Component } from "solid-js";
|
||||||
import { state } from "../../store/state";
|
import { state } from "../../store/state";
|
||||||
import { Member } from "../../components/Member";
|
import { Member } from "../../components/Member";
|
||||||
import { fetchCommunityMembers } from "../../services/community";
|
import { fetchCommunityMembers } from "../../services/community";
|
||||||
import { fetchUser } from "../../services/user";
|
import { fetchUser, getUserAvatarUrl } from "../../services/user";
|
||||||
|
|
||||||
const MemberView: Component = () => {
|
const MemberView: Component = () => {
|
||||||
const memberIds = createMemo(() => {
|
const memberIds = createMemo(() => {
|
||||||
|
|
@ -39,9 +39,7 @@ const MemberView: Component = () => {
|
||||||
<Member
|
<Member
|
||||||
id={member.id}
|
id={member.id}
|
||||||
username={member.username ?? ""}
|
username={member.username ?? ""}
|
||||||
avatar={
|
avatar={getUserAvatarUrl(member.avatar)}
|
||||||
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
|
|
||||||
}
|
|
||||||
active={false}
|
active={false}
|
||||||
onMemberClick={onMemberClick}
|
onMemberClick={onMemberClick}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createSignal, type Component, type JSXElement } from "solid-js";
|
import { createSignal, type Component, type JSXElement } from "solid-js";
|
||||||
import { ISettingsModalViewProps } from "./types";
|
import { ISettingsModalViewProps } from "./types";
|
||||||
import SettingsServersPage from "./pages/SettingsServersPage/SettingsServersPage";
|
import SettingsProfilePage from "./pages/SettingsProfilePage/SettingsProfilePage";
|
||||||
|
import SettingsCommunitiesPage from "./pages/SettingsCommunitiesPage/SettingsCommunitiesPage";
|
||||||
import SettingsSessionPage from "./pages/SettingsSessionsPage/SettingsSessionsPage";
|
import SettingsSessionPage from "./pages/SettingsSessionsPage/SettingsSessionsPage";
|
||||||
import { SettingsItem } from "../../components/SettingsItem";
|
import { SettingsItem } from "../../components/SettingsItem";
|
||||||
import { Dynamic } from "solid-js/web";
|
import { Dynamic } from "solid-js/web";
|
||||||
|
|
@ -13,8 +14,9 @@ const SettingsModalView: Component<ISettingsModalViewProps> = (
|
||||||
);
|
);
|
||||||
|
|
||||||
const pages = new Map<string, Component>([
|
const pages = new Map<string, Component>([
|
||||||
["Servers", SettingsServersPage],
|
["Profile", SettingsProfilePage],
|
||||||
["Sessions", SettingsSessionPage],
|
["Sessions", SettingsSessionPage],
|
||||||
|
["Communities", SettingsCommunitiesPage],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getCurrentPage = (): JSXElement => {
|
const getCurrentPage = (): JSXElement => {
|
||||||
|
|
@ -42,7 +44,7 @@ const SettingsModalView: Component<ISettingsModalViewProps> = (
|
||||||
ref={props.dialogRef}
|
ref={props.dialogRef}
|
||||||
class="modal outline-none bg-[#00000050]"
|
class="modal outline-none bg-[#00000050]"
|
||||||
>
|
>
|
||||||
<div class="modal-box w-10/12 max-w-5xl h-8/12 bg-stone-950 rounded-3xl">
|
<div class="modal-box w-10/12 max-w-5xl h-11/12 2xl:h-9/12 bg-stone-950 rounded-3xl">
|
||||||
<h3 class="text-lg font-bold text-center">Settings</h3>
|
<h3 class="text-lg font-bold text-center">Settings</h3>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<div class="flex flex-row gap-4 px-4">
|
<div class="flex flex-row gap-4 px-4">
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@ import {
|
||||||
import { DB_STORE, dbSaveEncrypted } from "../../../../services/database";
|
import { DB_STORE, dbSaveEncrypted } from "../../../../services/database";
|
||||||
import { fetchChannelMessages } from "../../../../services/channel";
|
import { fetchChannelMessages } from "../../../../services/channel";
|
||||||
|
|
||||||
const SettingsEncryptionPage: Component = () => {
|
const SettingsCommunitiesPage: Component = () => {
|
||||||
const [getSelectedCommunityId, setSelectedCommunityId] = createSignal<
|
const [getSelectedCommunityId, setSelectedCommunityId] = createSignal<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [getEncryptionKey, setEncryptionKey] = createSignal<string>("");
|
const [getSettingsEncryptionKey, setSettingsEncryptionKey] =
|
||||||
|
createSignal<string>("");
|
||||||
|
|
||||||
const getCommunity = (): ICommunity | undefined => {
|
const getCommunity = (): ICommunity | undefined => {
|
||||||
const selectedCommunityId = getSelectedCommunityId();
|
const selectedCommunityId = getSelectedCommunityId();
|
||||||
|
|
@ -37,7 +38,7 @@ const SettingsEncryptionPage: Component = () => {
|
||||||
|
|
||||||
setSelectedCommunityId(id);
|
setSelectedCommunityId(id);
|
||||||
|
|
||||||
setEncryptionKey(community.encryptionKey ?? "");
|
setSettingsEncryptionKey(community.encryptionKey ?? "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpdateEncryptionKey = () => {
|
const onUpdateEncryptionKey = () => {
|
||||||
|
|
@ -46,11 +47,11 @@ const SettingsEncryptionPage: Component = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCommunityEncryptionKey(community.id, getEncryptionKey());
|
setCommunityEncryptionKey(community.id, getSettingsEncryptionKey());
|
||||||
dbSaveEncrypted(
|
dbSaveEncrypted(
|
||||||
DB_STORE.COMMUNITY_ENCRYPTION_KEYS,
|
DB_STORE.COMMUNITY_ENCRYPTION_KEYS,
|
||||||
community.id,
|
community.id,
|
||||||
getEncryptionKey(),
|
getSettingsEncryptionKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (state.channel.active) {
|
if (state.channel.active) {
|
||||||
|
|
@ -97,20 +98,21 @@ const SettingsEncryptionPage: Component = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 class="text-lg font-bold text-center mb-5">
|
<h3 class="text-lg font-bold text-center mb-5">
|
||||||
{community.name}
|
{community.name} Settings
|
||||||
</h3>
|
</h3>
|
||||||
<h4 class="text-sm font-semibold mb-2 text-center">
|
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">
|
||||||
End-to-end encryption key
|
End-to-end encryption key
|
||||||
</h4>
|
</h4>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter encryption key"
|
placeholder="Enter encryption key"
|
||||||
outline={
|
outline={
|
||||||
(community.encryptionKey ?? "") !== getEncryptionKey()
|
(community.encryptionKey ?? "") !==
|
||||||
|
getSettingsEncryptionKey()
|
||||||
}
|
}
|
||||||
submitText="Update"
|
submitText="Update"
|
||||||
value={getEncryptionKey()}
|
value={getSettingsEncryptionKey()}
|
||||||
onChange={setEncryptionKey}
|
onChange={setSettingsEncryptionKey}
|
||||||
onSubmit={onUpdateEncryptionKey}
|
onSubmit={onUpdateEncryptionKey}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
@ -120,7 +122,7 @@ const SettingsEncryptionPage: Component = () => {
|
||||||
return (
|
return (
|
||||||
<div class="flex-1 flex flex-row gap-4">
|
<div class="flex-1 flex flex-row gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h3 class="text-lg font-bold text-center mb-4">Servers</h3>
|
<h3 class="text-lg font-bold text-center mb-4">Communities</h3>
|
||||||
{communityIds().map(mapCommunity)}
|
{communityIds().map(mapCommunity)}
|
||||||
</div>
|
</div>
|
||||||
<div class="divider divider-horizontal"></div>
|
<div class="divider divider-horizontal"></div>
|
||||||
|
|
@ -129,4 +131,4 @@ const SettingsEncryptionPage: Component = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsEncryptionPage;
|
export default SettingsCommunitiesPage;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./SettingsCommunitiesPage";
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { Component, createEffect, createSignal, onMount } from "solid-js";
|
||||||
|
import { Input } from "../../../../components/Input";
|
||||||
|
import {
|
||||||
|
getLoggedInUser,
|
||||||
|
setLoggedInUserAvatar,
|
||||||
|
setLoggedInUserDescription,
|
||||||
|
setLoggedInUserEmail,
|
||||||
|
setLoggedInUserNickname,
|
||||||
|
} from "../../../../store/user";
|
||||||
|
import { state } from "../../../../store/state";
|
||||||
|
import {
|
||||||
|
fetchUser,
|
||||||
|
getUserAvatarUrl,
|
||||||
|
updateUser,
|
||||||
|
} from "../../../../services/user";
|
||||||
|
import { FileInput } from "../../../../components/FileInput";
|
||||||
|
import { uploadUserAvatar } from "../../../../services/file";
|
||||||
|
|
||||||
|
const SettingsProfilePage: Component = () => {
|
||||||
|
const [getSettingsNickname, setSettingsNickname] = createSignal<string>("");
|
||||||
|
const [getSettingsEmail, setSettingsEmail] = createSignal<string>("");
|
||||||
|
const [getSettingsDescription, setSettingsDescription] =
|
||||||
|
createSignal<string>("");
|
||||||
|
const [getSettingsAvatar, setSettingsAvatar] = createSignal<
|
||||||
|
File | undefined
|
||||||
|
>();
|
||||||
|
const [getSettingsAvatarPreview, setSettingsAvatarPreview] = createSignal<
|
||||||
|
string | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (state.user.loggedUserId) {
|
||||||
|
fetchUser(state.user.loggedUserId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const user = getLoggedInUser();
|
||||||
|
if (user?.username) {
|
||||||
|
setSettingsNickname(user.nickname ?? user.username);
|
||||||
|
}
|
||||||
|
if (user?.email) {
|
||||||
|
setSettingsEmail(user.email);
|
||||||
|
}
|
||||||
|
if (user?.description) {
|
||||||
|
setSettingsDescription(user.description);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setAvatar = (files: FileList | null) => {
|
||||||
|
if (!files) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettingsAvatar(files[0]);
|
||||||
|
if (files[0]) {
|
||||||
|
setSettingsAvatarPreview(URL.createObjectURL(files[0]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUploadAvatar = async () => {
|
||||||
|
const avatar = getSettingsAvatar();
|
||||||
|
if (avatar) {
|
||||||
|
await uploadUserAvatar(avatar.name, avatar);
|
||||||
|
setLoggedInUserAvatar(avatar.name);
|
||||||
|
setSettingsAvatar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSaveProfile = () => {
|
||||||
|
const loggedInUser = getLoggedInUser();
|
||||||
|
if (!loggedInUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickname = getSettingsNickname().trim();
|
||||||
|
const email = getSettingsEmail().trim();
|
||||||
|
const description = getSettingsDescription().trim();
|
||||||
|
|
||||||
|
if (nickname.length > 0) {
|
||||||
|
setLoggedInUserNickname(nickname);
|
||||||
|
}
|
||||||
|
setLoggedInUserEmail(email);
|
||||||
|
setLoggedInUserDescription(description);
|
||||||
|
|
||||||
|
updateUser({
|
||||||
|
id: loggedInUser.id,
|
||||||
|
nickname: loggedInUser.nickname,
|
||||||
|
email: loggedInUser.email,
|
||||||
|
description: loggedInUser.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
onUploadAvatar();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex-1 flex flex-col gap-0 w-full">
|
||||||
|
<h3 class="text-lg font-bold text-center mb-5">
|
||||||
|
{getLoggedInUser()?.nickname}'s Profile
|
||||||
|
</h3>
|
||||||
|
<h4 class="text-sm font-semibold mb-2 text-center">
|
||||||
|
Profile picture
|
||||||
|
</h4>
|
||||||
|
<div class="self-center">
|
||||||
|
<FileInput
|
||||||
|
rounded={true}
|
||||||
|
picture={
|
||||||
|
getSettingsAvatarPreview() ??
|
||||||
|
getUserAvatarUrl(getLoggedInUser()?.avatar)
|
||||||
|
}
|
||||||
|
outline={getSettingsAvatar() !== undefined}
|
||||||
|
onChange={setAvatar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">Nickname</h4>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter nickname"
|
||||||
|
outline={
|
||||||
|
(getLoggedInUser()?.nickname ??
|
||||||
|
getLoggedInUser()?.username) !== getSettingsNickname()
|
||||||
|
}
|
||||||
|
value={getSettingsNickname()}
|
||||||
|
onChange={setSettingsNickname}
|
||||||
|
/>
|
||||||
|
<div class="py-3"></div>
|
||||||
|
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">Email</h4>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter email"
|
||||||
|
outline={
|
||||||
|
(getLoggedInUser()?.email ?? "") !== getSettingsEmail()
|
||||||
|
}
|
||||||
|
value={getSettingsEmail()}
|
||||||
|
onChange={setSettingsEmail}
|
||||||
|
/>
|
||||||
|
<div class="py-3"></div>
|
||||||
|
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">
|
||||||
|
Description
|
||||||
|
</h4>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Describe yourself"
|
||||||
|
outline={
|
||||||
|
(getLoggedInUser()?.description ?? "") !==
|
||||||
|
getSettingsDescription()
|
||||||
|
}
|
||||||
|
textArea={true}
|
||||||
|
value={getSettingsDescription()}
|
||||||
|
onChange={setSettingsDescription}
|
||||||
|
/>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button
|
||||||
|
class="bg-stone-950 btn rounded-xl h-14"
|
||||||
|
onClick={onSaveProfile}
|
||||||
|
>
|
||||||
|
Save Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsProfilePage;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./SettingsProfilePage";
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from "./SettingsServersPage";
|
|
||||||
|
|
@ -116,7 +116,8 @@ const SettingsSessionPage: Component = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-2 w-full">
|
<div class="flex-1 flex flex-col gap-2 w-full">
|
||||||
|
<h3 class="text-lg font-bold text-center mb-4">Sessions</h3>
|
||||||
{sessionIds().map(mapSession)}
|
{sessionIds().map(mapSession)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue