End to end encrypted attachment upload and streaming

This commit is contained in:
Aslan 2026-01-16 18:30:12 -05:00
parent 575e9e2010
commit 64ad8498f5
74 changed files with 2368 additions and 151 deletions

4
package-lock.json generated
View file

@ -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",

View file

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

View file

@ -51,7 +51,10 @@ interface IFetchChannelMessage {
id: string;
text: string;
iv: string;
replyToId?: string;
edited: boolean;
reactions: string[];
attachments: string[];
ownerId: string;
creationDate: number;
}

View file

@ -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
View 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
View file

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

90
src/api/file/types.ts Normal file
View 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,
};

View file

@ -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 {}

View file

@ -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,7 +31,33 @@ async function callApi(
},
);
break;
case HTTP.GET_BINARY:
return await fetch(
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,
{
method: "GET",
credentials: includeCookies ? "include" : "omit",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${state.auth.session?.token}`,
},
},
);
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}`,
{
@ -43,6 +71,7 @@ async function callApi(
},
);
break;
}
case HTTP.PATCH:
response = await fetch(
`${config.schema}://${config.url}:${config.port}/${config.path}/${path}`,

View file

@ -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,
};

View file

@ -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,
};

View file

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

View file

@ -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"}`}
>
{props.avatar ? (
<img src={props.avatar} />
) : (
<div class="bg-stone-800 p-2">
<ServerIcon />
</div>
)}
</div>
</div>
);

View file

@ -1,7 +1,7 @@
interface ICommunityProps {
id: string;
name: string;
avatar: string;
avatar?: string;
active: boolean;
onCommunityClick?: (id: string) => void;
}

View 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 };

View file

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

View file

@ -0,0 +1,9 @@
interface IFileInputProps {
rounded?: boolean;
picture?: string;
outline?: boolean;
multifile?: boolean;
onChange?: (files: FileList | null) => void;
}
export { type IFileInputProps };

View 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 };

View file

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

View 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 };

View file

@ -17,13 +17,7 @@ const Input: Component<IInputProps> = (props: IInputProps) => {
</button>
);
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" : ""}`}
>
<label
class={`bg-stone-800 input px-5 w-full h-full focus:border-none outline-none ${props.rounded ? "rounded-full" : "rounded-xl"}`}
>
const inputHtml = (): JSXElement => (
<input
type={props.type ? props.type : "text"}
placeholder={props.placeholder}
@ -31,6 +25,26 @@ const Input: Component<IInputProps> = (props: IInputProps) => {
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 ${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 ${props.textArea ? "px-0" : "px-5"} w-full h-full focus:border-none outline-none ${props.rounded ? "rounded-full" : "rounded-xl"}`}
>
{props.textArea ? textAreaHtml() : inputHtml()}
</label>
{props.submitText ? submitHtml() : undefined}
</div>

View file

@ -3,6 +3,7 @@ interface IInputProps {
type?: "text" | "password" | "email";
outline?: boolean;
rounded?: boolean;
textArea?: boolean;
placeholder?: string;
submitText?: string;
onChange?: (value: string) => void;

View file

@ -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">
{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>

View file

@ -1,7 +1,7 @@
interface IMemberProps {
id: string;
username: string;
avatar: string;
avatar?: string;
active: boolean;
onMemberClick?: (id: string) => void;
}

View file

@ -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">
const avatarHtml = (): JSXElement => (
<div
class="avatar cursor-pointer"
onClick={() => props.onProfileClick?.(props.userId)}
>
<div class="w-10 rounded-full">
{props.avatar ? (
<div class="w-10 h-10 rounded-full">
<img src={props.avatar} />
</div>
) : (
<div class="w-10 h-10 rounded-full bg-stone-900 p-2">
<UserIcon />
</div>
<div>
)}
</div>
);
const nameHtml = (): JSXElement => (
<div class="font-bold">{props.username}</div>
{props.decryptionStatus ? (
);
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>
);

View file

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

View file

@ -1,18 +1,182 @@
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();
}
};
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="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">
<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..."
@ -25,7 +189,7 @@ const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
</label>
<button
class="bg-stone-950/50 backdrop-blur-lg btn btn-neutral w-12 p-0 h-full rounded-full"
onClick={props.onSend}
onClick={onSend}
>
<div class="w-5">
<UpIcon />
@ -33,6 +197,7 @@ const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
</button>
</div>
</div>
</>
);
};

View file

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

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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;

View file

@ -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,
};

View file

@ -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);
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -94,6 +94,7 @@ const dbLoadEncrypted = async <T>(
}
const importedKey = await importKey(key);
try {
const decrypted = await decryptData<T>({
key: importedKey,
iv: iv,
@ -101,6 +102,9 @@ const dbLoadEncrypted = async <T>(
});
return decrypted;
} catch {
return undefined;
}
};
export {

341
src/services/file/file.ts Normal file
View 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,
};

View file

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

View 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,

View file

@ -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,
};

View file

@ -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,10 +46,8 @@ 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;
state.channel.channels[message.payload.channelId]?.communityId;
if (!communityId) {
break;
}
@ -56,7 +55,8 @@ const handleMessage = (message: SocketMessage) => {
communityId,
message.payload.message.text,
message.payload.message.iv,
).then((decrypted) => {
)
.then((decrypted) => {
if (decrypted) {
setChannelMessage(message.payload.channelId, {
...message.payload.message,
@ -69,13 +69,17 @@ const handleMessage = (message: SocketMessage) => {
decryptionStatus: false,
});
}
});
} catch {
})
.catch(() => {
setChannelMessage(message.payload.channelId, {
...message.payload.message,
decryptionStatus: false,
});
}
});
message.payload.message.attachments.forEach((attachmentId) => {
fetchMedia(communityId, attachmentId);
});
break;
}

View file

@ -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,

View file

@ -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,

View file

@ -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
View 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
View file

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

27
src/store/file/types.ts Normal file
View 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 };

View file

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

View file

@ -37,6 +37,10 @@ const [state, setState] = createStore<IState>({
message: {
message: undefined,
},
file: {
attachments: {},
chunks: {},
},
});
export { state, setState };

View file

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

View file

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

View file

@ -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,
};

View file

@ -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);
}
});

View file

@ -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) {
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}
/>

View file

@ -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}

View file

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

View file

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

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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