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",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulsar-web",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.15.4",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "pulsar-web",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ interface IFetchChannelMessage {
|
|||
id: string;
|
||||
text: string;
|
||||
iv: string;
|
||||
replyToId?: string;
|
||||
edited: boolean;
|
||||
reactions: string[];
|
||||
attachments: string[];
|
||||
ownerId: string;
|
||||
creationDate: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ interface IFetchCommunityMembersResponse extends IResponseSuccess {
|
|||
interface IFetchCommunityMember {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
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;
|
||||
text: string;
|
||||
iv: string;
|
||||
editHistory: string[];
|
||||
replyToId?: string;
|
||||
edited: boolean;
|
||||
editHistory: string[];
|
||||
reactions: string[];
|
||||
attachments: string[];
|
||||
ownerId: string;
|
||||
channelId: string;
|
||||
creationDate: number;
|
||||
|
|
@ -21,6 +24,8 @@ interface ICreateMessageRequest {
|
|||
text: string;
|
||||
iv: string;
|
||||
channelId: string;
|
||||
replyToId?: string;
|
||||
attachments: string[];
|
||||
}
|
||||
|
||||
interface ICreateMessageResponse extends IResponseSuccess, IFetchMessage {}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import config from "./config.json";
|
|||
|
||||
enum HTTP {
|
||||
GET,
|
||||
GET_BINARY,
|
||||
POST,
|
||||
PATCH,
|
||||
DELETE,
|
||||
|
|
@ -13,6 +14,7 @@ async function callApi(
|
|||
path: string,
|
||||
body?: object,
|
||||
includeCookies?: boolean,
|
||||
formData?: FormData,
|
||||
) {
|
||||
let response: Response;
|
||||
switch (type) {
|
||||
|
|
@ -29,20 +31,47 @@ async function callApi(
|
|||
},
|
||||
);
|
||||
break;
|
||||
case HTTP.POST:
|
||||
response = await fetch(
|
||||
case HTTP.GET_BINARY:
|
||||
return await fetch(
|
||||
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
|
||||
{
|
||||
method: "POST",
|
||||
method: "GET",
|
||||
credentials: includeCookies ? "include" : "omit",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
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:
|
||||
response = await fetch(
|
||||
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { IResponseSuccess } from "../types";
|
|||
interface IFetchUser {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
admin: boolean;
|
||||
|
|
@ -52,6 +53,15 @@ interface IFetchUserCommunity {
|
|||
name: string;
|
||||
}
|
||||
|
||||
interface IUpdateUserRequest {
|
||||
id: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface IUpdateUserResponse extends IResponseSuccess, IFetchUser {}
|
||||
|
||||
export {
|
||||
type IFetchUser,
|
||||
type IFetchLoggedUserResponse,
|
||||
|
|
@ -63,4 +73,6 @@ export {
|
|||
type IFetchUserCommunitiesRequest,
|
||||
type IFetchUserCommunitiesResponse,
|
||||
type IFetchUserCommunity,
|
||||
type IUpdateUserRequest,
|
||||
type IUpdateUserResponse,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
IFetchUserSessionsResponse,
|
||||
IFetchUserCommunitiesRequest,
|
||||
IFetchUserCommunitiesResponse,
|
||||
IUpdateUserRequest,
|
||||
IUpdateUserResponse,
|
||||
} from "./types";
|
||||
|
||||
const fetchLoggedUserApi = async (): Promise<
|
||||
|
|
@ -34,9 +36,16 @@ const fetchUserCommunitiesApi = async (
|
|||
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 {
|
||||
fetchLoggedUserApi,
|
||||
fetchUserApi,
|
||||
fetchUserSessionsApi,
|
||||
fetchUserCommunitiesApi,
|
||||
updateUserApi,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { IChannelBarProps } from "./types";
|
|||
const ChannelBar: Component<IChannelBarProps> = (props: IChannelBarProps) => {
|
||||
return (
|
||||
<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">
|
||||
{props.name ? `# ${props.name}` : undefined}
|
||||
</h2>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { ICommunityProps } from "./types";
|
||||
import { ServerIcon } from "../../icons";
|
||||
|
||||
const Community: Component<ICommunityProps> = (props: ICommunityProps) => {
|
||||
return (
|
||||
|
|
@ -10,7 +11,13 @@ const Community: Component<ICommunityProps> = (props: ICommunityProps) => {
|
|||
<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"}`}
|
||||
>
|
||||
<img src={props.avatar} />
|
||||
{props.avatar ? (
|
||||
<img src={props.avatar} />
|
||||
) : (
|
||||
<div class="bg-stone-800 p-2">
|
||||
<ServerIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
interface ICommunityProps {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
avatar?: string;
|
||||
active: boolean;
|
||||
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>
|
||||
);
|
||||
|
||||
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 (
|
||||
<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
|
||||
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
|
||||
type={props.type ? props.type : "text"}
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
onInput={(e) => props.onChange?.(e.currentTarget.value)}
|
||||
onKeyDown={handleEnter}
|
||||
/>
|
||||
{props.textArea ? textAreaHtml() : inputHtml()}
|
||||
</label>
|
||||
{props.submitText ? submitHtml() : undefined}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ interface IInputProps {
|
|||
type?: "text" | "password" | "email";
|
||||
outline?: boolean;
|
||||
rounded?: boolean;
|
||||
textArea?: boolean;
|
||||
placeholder?: string;
|
||||
submitText?: string;
|
||||
onChange?: (value: string) => void;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { IMemberProps } from "./types";
|
||||
import { UserIcon } from "../../icons";
|
||||
|
||||
const Member: Component<IMemberProps> = (props: IMemberProps) => {
|
||||
return (
|
||||
|
|
@ -8,9 +9,15 @@ const Member: Component<IMemberProps> = (props: IMemberProps) => {
|
|||
onClick={() => props.onMemberClick?.(props.id)}
|
||||
>
|
||||
<div class="avatar">
|
||||
<div class="w-9 rounded-full">
|
||||
<img src={props.avatar} />
|
||||
</div>
|
||||
{props.avatar ? (
|
||||
<div class="w-9 rounded-full">
|
||||
<img src={props.avatar} />
|
||||
</div>
|
||||
) : (
|
||||
<div class="w-9 rounded-full bg-stone-900 p-2">
|
||||
<UserIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="font-bold">{props.username}</div>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
interface IMemberProps {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
avatar?: string;
|
||||
active: boolean;
|
||||
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 {
|
||||
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) => {
|
||||
return (
|
||||
<li class="list-row p-3 hover:bg-stone-700">
|
||||
<div
|
||||
class="avatar cursor-pointer"
|
||||
onClick={() => props.onProfileClick?.(props.userId)}
|
||||
>
|
||||
<div class="w-10 rounded-full">
|
||||
const avatarHtml = (): JSXElement => (
|
||||
<div
|
||||
class="avatar cursor-pointer"
|
||||
onClick={() => props.onProfileClick?.(props.userId)}
|
||||
>
|
||||
{props.avatar ? (
|
||||
<div class="w-10 h-10 rounded-full">
|
||||
<img src={props.avatar} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">{props.username}</div>
|
||||
{props.decryptionStatus ? (
|
||||
) : (
|
||||
<div class="w-10 h-10 rounded-full bg-stone-900 p-2">
|
||||
<UserIcon />
|
||||
</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 italic">
|
||||
<p class="list-col-wrap text-xs italic opacity-75">
|
||||
Decryption failed
|
||||
</p>
|
||||
)}
|
||||
{props.attachments.length > 0 ? attachmentsHtml() : undefined}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ interface IMessageProps {
|
|||
message: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
avatar?: string;
|
||||
attachments: string[];
|
||||
plain: boolean;
|
||||
decryptionStatus: boolean;
|
||||
onProfileClick?: (userId: string) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,204 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { IMessageBarProps } from "./types";
|
||||
import { UpIcon } from "../../icons";
|
||||
import { createSignal, type Component, type JSXElement } from "solid-js";
|
||||
import { IMessageBarProps, MESSAGE_BAR_OPTIONS, OptionIcon } from "./types";
|
||||
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 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) => {
|
||||
if (e.key === "Enter") {
|
||||
props.onSend?.();
|
||||
onSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="absolute w-full bottom-0 p-4 z-10">
|
||||
<div class="bg-stone-800/25 backdrop-blur-lg h-16 shadow-bar p-2 flex flex-row gap-2 rounded-full">
|
||||
<label class="bg-stone-800/50 backdrop-blur-lg input w-full h-full rounded-full focus:border-none outline-none">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Send a message..."
|
||||
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={props.onSend}
|
||||
>
|
||||
<div class="w-5">
|
||||
<UpIcon />
|
||||
</div>
|
||||
</button>
|
||||
const openDropdown = () => {
|
||||
setDropdownOpen(true);
|
||||
timeouts.push(setTimeout(() => setDropdownOpen(false), 5000));
|
||||
};
|
||||
|
||||
const onOptionClick = (optionKey: MESSAGE_BAR_OPTIONS) => {
|
||||
setDropdownOpen(false);
|
||||
timeouts.forEach((timeout) => clearTimeout(timeout));
|
||||
|
||||
switch (optionKey) {
|
||||
case MESSAGE_BAR_OPTIONS.ATTACH_FILES:
|
||||
setAttachmentOpen(!getAttachmentOpen());
|
||||
break;
|
||||
case MESSAGE_BAR_OPTIONS.CREATE_POLL:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onFileAdd = (fileList: FileList | null) => {
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import { JSXElement } from "solid-js";
|
||||
import { IconParameters } from "../../icons";
|
||||
|
||||
interface IMessageBarProps {
|
||||
text: string;
|
||||
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 SettingsIcon from "./SettingsIcon";
|
||||
import ServerIcon from "./ServerIcon";
|
||||
import PlusIcon from "./PlusIcon";
|
||||
import MinusIcon from "./MinusIcon";
|
||||
import DeviceIcon from "./DeviceIcon";
|
||||
import TrashIcon from "./TrashIcon";
|
||||
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";
|
||||
|
||||
|
|
@ -12,9 +23,20 @@ export {
|
|||
IconParameters,
|
||||
HomeIcon,
|
||||
SettingsIcon,
|
||||
ServerIcon,
|
||||
PlusIcon,
|
||||
MinusIcon,
|
||||
DeviceIcon,
|
||||
TrashIcon,
|
||||
UpIcon,
|
||||
UploadIcon,
|
||||
UploadMultiIcon,
|
||||
AttachmentIcon,
|
||||
PollIcon,
|
||||
UserIcon,
|
||||
FileIcon,
|
||||
DownloadIcon,
|
||||
ErrorIcon,
|
||||
ZoomIcon,
|
||||
PictureIcon,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
setChannelMessages,
|
||||
} from "../../store/channel";
|
||||
import { IMessage } from "../../store/message";
|
||||
import { fetchMedia } from "../file";
|
||||
import { decryptMessage } from "../message";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import apiConfig from "../../api/config.json";
|
||||
|
||||
import {
|
||||
fetchCommunityApi,
|
||||
createCommunityApi,
|
||||
|
|
@ -7,6 +9,7 @@ import {
|
|||
fetchCommunityRolesApi,
|
||||
fetchCommunityMembersApi,
|
||||
fetchCommunityInvitesApi,
|
||||
IUpdateCommunityRequest,
|
||||
} from "../../api/community";
|
||||
import { setChannel } from "../../store/channel";
|
||||
import {
|
||||
|
|
@ -54,15 +57,9 @@ const createCommunity = async (name: string) => {
|
|||
};
|
||||
|
||||
const updateCommunity = async (
|
||||
id: string,
|
||||
name?: string,
|
||||
description?: string,
|
||||
updateCommunityData: IUpdateCommunityRequest,
|
||||
) => {
|
||||
const data = await updateCommunityApi({
|
||||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
});
|
||||
const data = await updateCommunityApi(updateCommunityData);
|
||||
|
||||
if (typeof data.error === "string") {
|
||||
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 {
|
||||
fetchCommunity,
|
||||
createCommunity,
|
||||
|
|
@ -186,4 +190,5 @@ export {
|
|||
fetchCommunityMembers,
|
||||
fetchCommunityInvites,
|
||||
loadCommunityCryptoStates,
|
||||
getCommunityAvatarUrl,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -60,6 +60,28 @@ const decryptData = async <T>(cryptoData: ICryptoEncrypted): Promise<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> => {
|
||||
return crypto.getRandomValues(new Uint8Array(12));
|
||||
};
|
||||
|
|
@ -84,12 +106,39 @@ const bytesToHex = (bytes: ArrayBuffer): string => {
|
|||
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 {
|
||||
importKey,
|
||||
deriveKey,
|
||||
encryptData,
|
||||
decryptData,
|
||||
encryptBytes,
|
||||
decryptBytes,
|
||||
generateIv,
|
||||
hexToBytes,
|
||||
bytesToHex,
|
||||
bufferToBase64,
|
||||
base64ToBuffer,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -94,13 +94,17 @@ const dbLoadEncrypted = async <T>(
|
|||
}
|
||||
|
||||
const importedKey = await importKey(key);
|
||||
const decrypted = await decryptData<T>({
|
||||
key: importedKey,
|
||||
iv: iv,
|
||||
encryptedData: item.data,
|
||||
});
|
||||
try {
|
||||
const decrypted = await decryptData<T>({
|
||||
key: importedKey,
|
||||
iv: iv,
|
||||
encryptedData: item.data,
|
||||
});
|
||||
|
||||
return decrypted;
|
||||
return decrypted;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
removeMessageApi,
|
||||
} from "../../api/message";
|
||||
import { deleteAttachment } from "../../store/file";
|
||||
import { deleteMessage, setMessage } from "../../store/message";
|
||||
import { state } from "../../store/state";
|
||||
import {
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
generateIv,
|
||||
hexToBytes,
|
||||
} from "../crypto";
|
||||
import { fetchMedia } from "../file";
|
||||
|
||||
const fetchMessage = async (id: string, communityId: string) => {
|
||||
const data = await fetchMessageApi({
|
||||
|
|
@ -34,12 +36,18 @@ const fetchMessage = async (id: string, communityId: string) => {
|
|||
text: decrypted,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
data.attachments.forEach((attachmentId) => {
|
||||
fetchMedia(communityId, attachmentId);
|
||||
});
|
||||
};
|
||||
|
||||
const createMessage = async (
|
||||
communityId: string,
|
||||
channelId: string,
|
||||
text: string,
|
||||
attachments: string[],
|
||||
replyToId?: string,
|
||||
) => {
|
||||
const encrypted = await encryptMessage(communityId, text);
|
||||
if (!encrypted) {
|
||||
|
|
@ -48,9 +56,11 @@ const createMessage = async (
|
|||
const [encryptedMessage, iv] = encrypted;
|
||||
|
||||
const data = await createMessageApi({
|
||||
channelId: channelId,
|
||||
iv: iv,
|
||||
text: encryptedMessage,
|
||||
iv: iv,
|
||||
channelId: channelId,
|
||||
replyToId: replyToId,
|
||||
attachments: attachments,
|
||||
});
|
||||
|
||||
if (typeof data.error === "string") {
|
||||
|
|
@ -61,6 +71,10 @@ const createMessage = async (
|
|||
...data,
|
||||
text: text,
|
||||
});
|
||||
|
||||
attachments.forEach((attachmentId) => {
|
||||
deleteAttachment(attachmentId);
|
||||
});
|
||||
};
|
||||
|
||||
const updateMessage = async (id: string, text: string) => {
|
||||
|
|
@ -98,6 +112,11 @@ const encryptMessage = async (
|
|||
}
|
||||
|
||||
const iv = generateIv();
|
||||
|
||||
if (text.length === 0) {
|
||||
return ["", bytesToHex(iv.buffer)];
|
||||
}
|
||||
|
||||
const encrypted = await encryptData<string>({
|
||||
key: key,
|
||||
iv: iv,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import apiConfig from "../../api/config.json";
|
||||
|
||||
import {
|
||||
fetchLoggedUserApi,
|
||||
fetchUserApi,
|
||||
fetchUserSessionsApi,
|
||||
fetchUserCommunitiesApi,
|
||||
updateUserApi,
|
||||
IUpdateUserRequest,
|
||||
} from "../../api/user";
|
||||
import { setCommunity } from "../../store/community";
|
||||
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,
|
||||
fetchCommunityRoles,
|
||||
} from "../community";
|
||||
import { fetchMedia } from "../file";
|
||||
import { decryptMessage } from "../message";
|
||||
import config from "./config.json";
|
||||
import { SocketMessage, SocketMessageTypes } from "./types";
|
||||
|
|
@ -45,18 +46,17 @@ const parseMessage = (data: string): SocketMessage | null => {
|
|||
const handleMessage = (message: SocketMessage) => {
|
||||
switch (message.type) {
|
||||
case SocketMessageTypes.SET_MESSAGE: {
|
||||
try {
|
||||
const communityId =
|
||||
state.channel.channels[message.payload.channelId]
|
||||
?.communityId;
|
||||
if (!communityId) {
|
||||
break;
|
||||
}
|
||||
decryptMessage(
|
||||
communityId,
|
||||
message.payload.message.text,
|
||||
message.payload.message.iv,
|
||||
).then((decrypted) => {
|
||||
const communityId =
|
||||
state.channel.channels[message.payload.channelId]?.communityId;
|
||||
if (!communityId) {
|
||||
break;
|
||||
}
|
||||
decryptMessage(
|
||||
communityId,
|
||||
message.payload.message.text,
|
||||
message.payload.message.iv,
|
||||
)
|
||||
.then((decrypted) => {
|
||||
if (decrypted) {
|
||||
setChannelMessage(message.payload.channelId, {
|
||||
...message.payload.message,
|
||||
|
|
@ -69,13 +69,17 @@ const handleMessage = (message: SocketMessage) => {
|
|||
decryptionStatus: false,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setChannelMessage(message.payload.channelId, {
|
||||
...message.payload.message,
|
||||
decryptionStatus: false,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
setChannelMessage(message.payload.channelId, {
|
||||
...message.payload.message,
|
||||
decryptionStatus: false,
|
||||
});
|
||||
}
|
||||
|
||||
message.payload.message.attachments.forEach((attachmentId) => {
|
||||
fetchMedia(communityId, attachmentId);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { IMessage } from "../message";
|
||||
import { setState } from "../state";
|
||||
import { setState, state } from "../state";
|
||||
import { IChannel } from "./types";
|
||||
|
||||
const getActiveChannel = (): IChannel | undefined => {
|
||||
if (!state.channel.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
return state.channel.channels[state.channel.active];
|
||||
};
|
||||
|
||||
const setChannel = (channel: IChannel) => {
|
||||
setState("channel", "channels", channel.id, channel);
|
||||
};
|
||||
|
|
@ -49,6 +57,7 @@ const deleteChannelMessage = (channelId: string, messageId: string) => {
|
|||
};
|
||||
|
||||
export {
|
||||
getActiveChannel,
|
||||
setChannel,
|
||||
deleteChannel,
|
||||
setActiveChannel,
|
||||
|
|
|
|||
|
|
@ -2,14 +2,61 @@ import { deriveKey } from "../../services/crypto";
|
|||
import { IChannel } from "../channel";
|
||||
import { IInvite } from "../invite";
|
||||
import { IRole } from "../role";
|
||||
import { setState } from "../state";
|
||||
import { setState, state } from "../state";
|
||||
import { IUser } from "../user";
|
||||
import { ICommunity } from "./types";
|
||||
|
||||
const getActiveCommunity = (): ICommunity | undefined => {
|
||||
if (!state.community.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
return state.community.communities[state.community.active];
|
||||
};
|
||||
|
||||
const setCommunity = (community: ICommunity) => {
|
||||
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) => {
|
||||
setState("community", "communities", communityId, undefined);
|
||||
};
|
||||
|
|
@ -68,7 +115,14 @@ const setCommunityDerivedKey = (communityId: string, derivedKey: CryptoKey) => {
|
|||
};
|
||||
|
||||
export {
|
||||
getActiveCommunity,
|
||||
setCommunity,
|
||||
setCommunityAvatar,
|
||||
setActiveCommunityAvatar,
|
||||
setCommunityName,
|
||||
setActiveCommunityName,
|
||||
setCommunityDescription,
|
||||
setActiveCommunityDescription,
|
||||
deleteCommunity,
|
||||
setActiveCommunity,
|
||||
resetActiveCommunity,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface ICommunity {
|
|||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
owner?: string;
|
||||
creationDate?: number;
|
||||
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;
|
||||
text: string;
|
||||
iv: string;
|
||||
editHistory?: string[];
|
||||
replyToId?: string;
|
||||
edited: boolean;
|
||||
editHistory?: string[];
|
||||
reactions: string[];
|
||||
attachments: string[];
|
||||
ownerId: string;
|
||||
channelId?: string;
|
||||
creationDate: number;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ const [state, setState] = createStore<IState>({
|
|||
message: {
|
||||
message: undefined,
|
||||
},
|
||||
file: {
|
||||
attachments: {},
|
||||
chunks: {},
|
||||
},
|
||||
});
|
||||
|
||||
export { state, setState };
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { IRoleState } from "./role";
|
|||
import { ISessionState } from "./session";
|
||||
import { IInviteState } from "./invite";
|
||||
import { IMessageState } from "./message";
|
||||
import { IFileState } from "./file";
|
||||
|
||||
interface IState {
|
||||
app: IAppState;
|
||||
|
|
@ -18,6 +19,7 @@ interface IState {
|
|||
session: ISessionState;
|
||||
invite: IInviteState;
|
||||
message: IMessageState;
|
||||
file: IFileState;
|
||||
}
|
||||
|
||||
export { type IState };
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ interface IUserState {
|
|||
interface IUser {
|
||||
id: string;
|
||||
username?: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
admin?: boolean;
|
||||
registerDate?: number;
|
||||
lastLogin?: number;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,65 @@ import {
|
|||
IFetchUserSessionsResponse,
|
||||
} from "../../api/user";
|
||||
import { loadCommunityCryptoStates } from "../../services/community";
|
||||
import { setState } from "../state";
|
||||
import { setState, state } from "../state";
|
||||
import { IUser } from "./types";
|
||||
|
||||
const getLoggedInUser = (): IUser | undefined => {
|
||||
if (!state.user.loggedUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return state.user.users[state.user.loggedUserId];
|
||||
};
|
||||
|
||||
const setUser = (user: IUser) => {
|
||||
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) => {
|
||||
setState("user", "loggedUserId", userId);
|
||||
};
|
||||
|
|
@ -28,4 +80,18 @@ const setUserCommunities = (communities: IFetchUserCommunitiesResponse) => {
|
|||
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 { connectWs } from "../../services/websocket";
|
||||
import { fetchRefresh } from "../../services/auth";
|
||||
import { fetchLoggedUser, fetchUserCommunities } from "../../services/user";
|
||||
import {
|
||||
fetchLoggedUser,
|
||||
fetchUser,
|
||||
fetchUserCommunities,
|
||||
} from "../../services/user";
|
||||
|
||||
const AppView: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -26,6 +30,7 @@ const AppView: Component = () => {
|
|||
|
||||
createEffect(() => {
|
||||
if (state.user.loggedUserId) {
|
||||
fetchUser(state.user.loggedUserId);
|
||||
fetchUserCommunities(state.user.loggedUserId);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@ import { Message } from "../../components/Message";
|
|||
import { state } from "../../store/state";
|
||||
import { IChannel, setText } from "../../store/channel";
|
||||
import { fetchChannelMessages } from "../../services/channel";
|
||||
import { fetchUser } from "../../services/user";
|
||||
import { fetchUser, getUserAvatarUrl } from "../../services/user";
|
||||
import { createMessage } from "../../services/message";
|
||||
import { IMessage } from "../../store/message";
|
||||
import {
|
||||
createAttachment,
|
||||
finishAttachment,
|
||||
uploadChunks,
|
||||
} from "../../services/file";
|
||||
import { getActiveCommunity } from "../../store/community";
|
||||
|
||||
const ChatView: Component = () => {
|
||||
let scrollRef: HTMLUListElement | undefined;
|
||||
|
|
@ -99,21 +105,67 @@ const ChatView: Component = () => {
|
|||
setText(channel.id, text);
|
||||
};
|
||||
|
||||
const onMessageSend = () => {
|
||||
const onMessageSend = async (files: File[]) => {
|
||||
autoScroll = true;
|
||||
|
||||
const channel = channelInfo();
|
||||
if (!channel?.id || !state.community.active) {
|
||||
const community = getActiveCommunity();
|
||||
if (!channel?.id || !community?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = channel.text;
|
||||
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, "");
|
||||
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 (
|
||||
|
|
@ -129,18 +181,22 @@ const ChatView: Component = () => {
|
|||
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"
|
||||
>
|
||||
{messages().map((message) => (
|
||||
{messages().map((message, messageIndex, allMessages) => (
|
||||
<Message
|
||||
messageId={message?.id ?? ""}
|
||||
message={message?.text ?? ""}
|
||||
userId={message?.ownerId ?? ""}
|
||||
messageId={message.id ?? ""}
|
||||
message={message.text}
|
||||
userId={message.ownerId}
|
||||
username={
|
||||
state.user.users[message?.ownerId ?? ""]
|
||||
.username ?? ""
|
||||
}
|
||||
avatar={
|
||||
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
|
||||
state.user.users[message.ownerId].username ?? ""
|
||||
}
|
||||
avatar={getUserAvatarUrl(
|
||||
state.user.users[message.ownerId].avatar,
|
||||
)}
|
||||
attachments={message.attachments}
|
||||
plain={isPlainMessage(
|
||||
message,
|
||||
allMessages[messageIndex - 1],
|
||||
)}
|
||||
decryptionStatus={message.decryptionStatus ?? false}
|
||||
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 CommunitySettingsProfilePage from "./pages/SettingsProfilePage/CommunitySettingsProfilePage";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
import { SettingsItem } from "../../components/SettingsItem";
|
||||
import { getActiveCommunity } from "../../store/community";
|
||||
|
||||
const CommunitySettingsModalView: Component<
|
||||
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 (
|
||||
<div>
|
||||
<dialog
|
||||
ref={props.dialogRef}
|
||||
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">
|
||||
Community Settings
|
||||
{getActiveCommunity()?.name} Settings
|
||||
</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>
|
||||
<form
|
||||
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,
|
||||
setActiveCommunity,
|
||||
} from "../../store/community";
|
||||
import { fetchCommunity } from "../../services/community";
|
||||
import {
|
||||
fetchCommunity,
|
||||
getCommunityAvatarUrl,
|
||||
} from "../../services/community";
|
||||
import { resetActiveChannel } from "../../store/channel";
|
||||
|
||||
const CommunityView: Component = () => {
|
||||
|
|
@ -63,9 +66,7 @@ const CommunityView: Component = () => {
|
|||
<Community
|
||||
id={community.id}
|
||||
name={community.name ?? ""}
|
||||
avatar={
|
||||
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
|
||||
}
|
||||
avatar={getCommunityAvatarUrl(community.avatar)}
|
||||
active={community.id === state.community.active}
|
||||
onCommunityClick={onCommunityClick}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createEffect, createMemo, type Component } from "solid-js";
|
|||
import { state } from "../../store/state";
|
||||
import { Member } from "../../components/Member";
|
||||
import { fetchCommunityMembers } from "../../services/community";
|
||||
import { fetchUser } from "../../services/user";
|
||||
import { fetchUser, getUserAvatarUrl } from "../../services/user";
|
||||
|
||||
const MemberView: Component = () => {
|
||||
const memberIds = createMemo(() => {
|
||||
|
|
@ -39,9 +39,7 @@ const MemberView: Component = () => {
|
|||
<Member
|
||||
id={member.id}
|
||||
username={member.username ?? ""}
|
||||
avatar={
|
||||
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
|
||||
}
|
||||
avatar={getUserAvatarUrl(member.avatar)}
|
||||
active={false}
|
||||
onMemberClick={onMemberClick}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createSignal, type Component, type JSXElement } from "solid-js";
|
||||
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 { SettingsItem } from "../../components/SettingsItem";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
|
|
@ -13,8 +14,9 @@ const SettingsModalView: Component<ISettingsModalViewProps> = (
|
|||
);
|
||||
|
||||
const pages = new Map<string, Component>([
|
||||
["Servers", SettingsServersPage],
|
||||
["Profile", SettingsProfilePage],
|
||||
["Sessions", SettingsSessionPage],
|
||||
["Communities", SettingsCommunitiesPage],
|
||||
]);
|
||||
|
||||
const getCurrentPage = (): JSXElement => {
|
||||
|
|
@ -42,7 +44,7 @@ const SettingsModalView: Component<ISettingsModalViewProps> = (
|
|||
ref={props.dialogRef}
|
||||
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>
|
||||
<div class="divider"></div>
|
||||
<div class="flex flex-row gap-4 px-4">
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ import {
|
|||
import { DB_STORE, dbSaveEncrypted } from "../../../../services/database";
|
||||
import { fetchChannelMessages } from "../../../../services/channel";
|
||||
|
||||
const SettingsEncryptionPage: Component = () => {
|
||||
const SettingsCommunitiesPage: Component = () => {
|
||||
const [getSelectedCommunityId, setSelectedCommunityId] = createSignal<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [getEncryptionKey, setEncryptionKey] = createSignal<string>("");
|
||||
const [getSettingsEncryptionKey, setSettingsEncryptionKey] =
|
||||
createSignal<string>("");
|
||||
|
||||
const getCommunity = (): ICommunity | undefined => {
|
||||
const selectedCommunityId = getSelectedCommunityId();
|
||||
|
|
@ -37,7 +38,7 @@ const SettingsEncryptionPage: Component = () => {
|
|||
|
||||
setSelectedCommunityId(id);
|
||||
|
||||
setEncryptionKey(community.encryptionKey ?? "");
|
||||
setSettingsEncryptionKey(community.encryptionKey ?? "");
|
||||
};
|
||||
|
||||
const onUpdateEncryptionKey = () => {
|
||||
|
|
@ -46,11 +47,11 @@ const SettingsEncryptionPage: Component = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
setCommunityEncryptionKey(community.id, getEncryptionKey());
|
||||
setCommunityEncryptionKey(community.id, getSettingsEncryptionKey());
|
||||
dbSaveEncrypted(
|
||||
DB_STORE.COMMUNITY_ENCRYPTION_KEYS,
|
||||
community.id,
|
||||
getEncryptionKey(),
|
||||
getSettingsEncryptionKey(),
|
||||
);
|
||||
|
||||
if (state.channel.active) {
|
||||
|
|
@ -97,20 +98,21 @@ const SettingsEncryptionPage: Component = () => {
|
|||
return (
|
||||
<>
|
||||
<h3 class="text-lg font-bold text-center mb-5">
|
||||
{community.name}
|
||||
{community.name} Settings
|
||||
</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
|
||||
</h4>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter encryption key"
|
||||
outline={
|
||||
(community.encryptionKey ?? "") !== getEncryptionKey()
|
||||
(community.encryptionKey ?? "") !==
|
||||
getSettingsEncryptionKey()
|
||||
}
|
||||
submitText="Update"
|
||||
value={getEncryptionKey()}
|
||||
onChange={setEncryptionKey}
|
||||
value={getSettingsEncryptionKey()}
|
||||
onChange={setSettingsEncryptionKey}
|
||||
onSubmit={onUpdateEncryptionKey}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -120,7 +122,7 @@ const SettingsEncryptionPage: Component = () => {
|
|||
return (
|
||||
<div class="flex-1 flex flex-row gap-4">
|
||||
<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)}
|
||||
</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 (
|
||||
<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)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue