Upload/Download progress; Mobile view

This commit is contained in:
Aslan 2026-01-21 11:03:51 -05:00
parent 6b6bbdc142
commit c1f24e3d41
35 changed files with 491 additions and 180 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "pulsar-web",
"version": "0.6.0",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulsar-web",
"version": "0.6.0",
"version": "0.7.1",
"license": "MIT",
"dependencies": {
"@solidjs/router": "^0.15.4",

View file

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

View file

@ -1,15 +1,30 @@
import type { Component } from "solid-js";
import { IChannelBarProps } from "./types";
import { LeftIcon, UserIcon } from "../../icons";
import { resetActiveChannel } from "../../store/channel";
import { setMembersOpenMobile } from "../../store/app";
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-900/25 backdrop-blur-md h-16 w-full shadow-bar px-5">
<div class="absolute w-full flex flex-row top-0 z-10 bg-stone-900/25 backdrop-blur-md h-16 shadow-bar">
<div
class="block lg:hidden h-full ml-1 w-16 p-5 cursor-pointer"
onClick={resetActiveChannel}
>
<LeftIcon />
</div>
<div class="flex-1 flex flex-col justify-center px-5">
<h2 class="text-sm font-bold">
{props.name ? `# ${props.name}` : undefined}
</h2>
<p class="text-xs">{props.description}</p>
</div>
<div
class="block lg:hidden h-full ml-1 w-16 p-5 cursor-pointer"
onClick={() => setMembersOpenMobile(true)}
>
<UserIcon />
</div>
</div>
);
};

View file

@ -32,7 +32,7 @@ const FileInput: Component<IFileInputProps> = (props: IFileInputProps) => {
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 border-2 border-stone-700 cursor-pointer ${props.rounded ? "rounded-full" : "rounded-xl"}`}
class={`relative inline-block bg-stone-900 input w-full h-full p-0 border-none outline-2 outline-stone-700 cursor-pointer ${props.rounded ? "rounded-full" : "rounded-xl"}`}
>
{props.picture ? pictureHtml() : iconOnlyHtml()}
<input

View file

@ -1,37 +1,58 @@
import { type Component } from "solid-js";
import { JSXElement, type Component } from "solid-js";
import { IFilePreviewProps } from "./types";
import { Dynamic } from "solid-js/web";
const FilePreview: Component<IFilePreviewProps> = (
props: IFilePreviewProps,
) => {
const iconHtml = (): JSXElement => (
<div class="w-full h-full flex flex-col items-center justify-center min-w-0">
<div class="w-12">
<Dynamic component={props.icon} />
</div>
<p class="w-full text-center px-2 truncate">{props.filename}</p>
</div>
);
const pictureHtml = (): JSXElement => (
<div class="avatar w-full h-full">
<img
class={props.rounded ? "rounded-full" : "rounded-xl"}
src={props.picture}
/>
</div>
);
const downloadHtml = (): JSXElement => (
<div class="w-full h-full flex flex-col items-center justify-center min-w-0">
<div
class="radial-progress"
style={{
"--value": props.downloadProgress ?? 0,
"--size": "4rem",
"--thickness": "7px",
}}
role="progressbar"
>
{(props.downloadProgress ?? 0).toFixed(0)}%
</div>
<p class="w-full text-center px-2 truncate">{props.filename}</p>
</div>
);
return (
<div
class={`bg-stone-800 p-2 ${props.allowResize ? "min-h-40 min-w-40 max-h-fit max-w-82" : "h-40 w-40"} ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`}
>
<label
class={`relative inline-block bg-stone-900 w-full h-full p-0 border-2 border-stone-700 focus:border-none outline-none ${props.clickable ? "cursor-pointer" : "cursor-default"} ${props.rounded ? "rounded-full" : "rounded-xl"}`}
class={`relative inline-block bg-stone-900 w-full h-full p-0 outline-2 outline-stone-700 ${props.clickable && props.downloadProgress === undefined ? "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 min-w-0">
<div class="w-12">
<Dynamic component={props.icon} />
</div>
<p class="w-full text-center px-2 truncate">
{props.filename}
</p>
</div>
)}
{props.clickable ? (
{props.downloadProgress === undefined
? props.picture
? pictureHtml()
: iconHtml()
: downloadHtml()}
{props.clickable && props.downloadProgress === undefined ? (
<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)}

View file

@ -10,6 +10,7 @@ interface IFilePreviewProps {
outline?: boolean;
allowResize?: boolean;
clickable?: boolean;
downloadProgress?: number;
clickIcon?: (props: IconParameters) => JSXElement;
onClick?: (id: string | number) => void;
}

View file

@ -4,8 +4,8 @@ import { Dynamic } from "solid-js/web";
const HomeCard: Component<IHomeCardProps> = (props: IHomeCardProps) => {
return (
<a class="w-60 cursor-pointer" onClick={props.onClick}>
<div class="card outline-2 bg-stone-800 outline-stone-500 hover:outline-stone-100 w-60 h-60">
<a class="cursor-pointer" onClick={props.onClick}>
<div class="card transition-all border-2 bg-stone-800 border-stone-500 hover:border-stone-100 w-60 h-60">
<div class="flex flex-col h-full gap-1 m-6">
<div class="w-20">
<Dynamic component={props.icon} />

View file

@ -13,14 +13,20 @@ import { FilePreview } from "../FilePreview";
import { fetchFile } from "../../services/file";
import { getActiveCommunity } from "../../store/community";
import { Input } from "../Input";
import { IAttachment } from "../../store/file";
const Message: Component<IMessageProps> = (props: IMessageProps) => {
const [getEditedText, setEditedText] = createSignal<string>(props.message);
const [getEditedText, setEditedText] = createSignal<string>(
props.message.text,
);
const [getDownloadProgress, setDownloadProgress] = createSignal<
Map<number, number>
>(new Map<number, number>());
const avatarHtml = (): JSXElement => (
<div
class="avatar cursor-pointer"
onClick={() => props.onProfileClick?.(props.userId)}
onClick={() => props.onProfileClick?.(props.message.ownerId)}
>
{props.avatar ? (
<div class="w-10 h-10 rounded-full">
@ -35,7 +41,7 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
);
const onUpdateMessage = () => {
props.onMessageEdit?.(props.messageId, getEditedText());
props.onMessageEdit?.(props.message.id, getEditedText());
};
const onDownloadAttachment = (attachmentId: string | number) => {
@ -44,9 +50,36 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
return;
}
const updateProgress = (chunkIndex: number, totalChunks: number) => {
const fileProgress = (chunkIndex + 1) / totalChunks;
if (fileProgress === 1) {
setDownloadProgress((original) => {
const next = new Map(original);
next.delete(Number(attachmentId));
return next;
});
} else {
setDownloadProgress((original) => {
const next = new Map(original);
next.set(Number(attachmentId), fileProgress * 100);
return next;
});
}
};
const community = getActiveCommunity();
if (community) {
fetchFile(community.id, attachmentId.toString());
setDownloadProgress((original) => {
const next = new Map(original);
next.set(Number(attachmentId), 0);
return next;
});
fetchFile(community.id, attachmentId.toString(), updateProgress);
}
};
@ -77,11 +110,14 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
}
if (!attachment.mimetype.startsWith("image/")) {
const progress = getDownloadProgress().get(Number(attachment.id));
return (
<FilePreview
id={attachmentId}
icon={FileIcon}
filename={attachment.filename}
downloadProgress={progress}
clickable={true}
clickIcon={DownloadIcon}
onClick={onDownloadAttachment}
@ -103,7 +139,7 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
<FilePreview
id={attachmentId}
picture={URL.createObjectURL(attachment.fullFile)}
allowResize={props.attachments.length === 1}
allowResize={props.message.attachments.length === 1}
clickable={true}
clickIcon={ZoomIcon}
onClick={onOpenAttachment}
@ -113,7 +149,7 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
const attachmentsHtml = (): JSXElement => (
<div class="mt-2 flex flex-row flex-wrap gap-2">
{props.attachments.map(mapAttachment)}
{props.message.attachments.map(mapAttachment)}
</div>
);
@ -123,14 +159,18 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
</div>
);
const timeHtml = (): JSXElement => (
<div class="flex-1 text-[0.6rem] opacity-40 text-end text-nowrap">
{new Date(props.time).toLocaleTimeString()}
const statusHtml = (): JSXElement => (
<div class="h-full flex flex-col justify-between text-[0.6rem] opacity-40">
<div class="text-nowrap">
{props.message.notSentYet
? "Sending..."
: new Date(props.message.creationDate).toLocaleTimeString()}
</div>
</div>
);
const editingHtml = (): JSXElement => (
<div class="">
<div>
<Input
value={getEditedText()}
onChange={setEditedText}
@ -141,9 +181,10 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
const messageHtml = (): JSXElement => (
<>
{props.message.length === 0 ? undefined : props.decryptionStatus ? (
{props.message.text.length === 0 ? undefined : props.message
.decryptionStatus ? (
<p class="list-col-wrap text-xs">
{props.editing ? editingHtml() : props.message}
{props.editing ? editingHtml() : props.message.text}
</p>
) : (
<p class="list-col-wrap text-xs italic opacity-75">
@ -157,16 +198,18 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
<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"}`}
onContextMenu={(e) =>
props.onMessageRightClick?.(props.messageId, e)
props.onMessageRightClick?.(props.message.id, e)
}
>
{props.plain ? undefined : avatarHtml()}
<div class={`w-full pr-14 ${props.plain ? "pl-14" : ""}`}>
<div class={`w-full pr-4 ${props.plain ? "pl-14" : ""}`}>
{props.plain ? undefined : nameHtml()}
{messageHtml()}
{props.attachments.length > 0 ? attachmentsHtml() : undefined}
{props.message.attachments.length > 0
? attachmentsHtml()
: undefined}
</div>
{timeHtml()}
{statusHtml()}
</li>
);
};

View file

@ -1,14 +1,11 @@
import { IMessage } from "../../store/message";
interface IMessageProps {
messageId: string;
message: string;
userId: string;
message: IMessage;
username: string;
color?: string;
avatar?: string;
attachments: string[];
time: number;
plain: boolean;
decryptionStatus: boolean;
editing: boolean;
onProfileClick?: (userId: string) => void;
onMessageRightClick?: (id: string, event: MouseEvent) => void;

View file

@ -1,7 +1,13 @@
import { createSignal, type Component, type JSXElement } from "solid-js";
import {
createEffect,
createSignal,
type Component,
type JSXElement,
} from "solid-js";
import { IMessageBarProps, MESSAGE_BAR_OPTIONS, OptionIcon } from "./types";
import {
AttachmentIcon,
LocationIcon,
PlusIcon,
PollIcon,
TrashIcon,
@ -24,9 +30,19 @@ const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
const options = new Map<MESSAGE_BAR_OPTIONS, OptionIcon>([
[MESSAGE_BAR_OPTIONS.ATTACH_FILES, AttachmentIcon],
[MESSAGE_BAR_OPTIONS.ATTACH_LOCATION, LocationIcon],
[MESSAGE_BAR_OPTIONS.CREATE_POLL, PollIcon],
]);
createEffect(() => {
if (props.uploadProgress === undefined) {
setAttachmentOpen(false);
setFiles([]);
setFilePreviews([]);
}
});
const handleEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") {
onSend();
@ -95,10 +111,6 @@ const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
props.onSend?.(getFiles());
setDropdownOpen(false);
setAttachmentOpen(false);
setFiles([]);
setFilePreviews([]);
};
const mapOption = (
@ -126,7 +138,7 @@ const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
id={index}
icon={AttachmentIcon}
filename={getFiles().at(index)?.name}
clickable={true}
clickable={!props.uploadProgress}
clickIcon={TrashIcon}
picture={filePreview}
onClick={onFileRemove}
@ -139,7 +151,48 @@ const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
<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} />
<FileInput
multifile={true}
onChange={(fileList) =>
!props.uploadProgress
? onFileAdd?.(fileList)
: undefined
}
/>
</div>
</div>
</div>
);
const uploadingHtml = (): JSXElement => (
<div class="absolute w-full bottom-18 p-4 z-10">
<div class="flex flex-row gap-2 bg-stone-800/50 backdrop-blur-lg h-16 w-64 shadow-bar rounded-full mx-auto p-1">
<div
class="radial-progress"
style={{
"--value": props.uploadProgress?.total,
"--size": "3.5rem",
"--thickness": "7px",
}}
role="progressbar"
>
{props.uploadProgress?.total.toFixed(0)}%
</div>
<div class="flex-1 flex flex-col justify-center items-center">
{props.uploadProgress?.total === 0 ? (
<span>Starting upload...</span>
) : (
<span>
Uploading file {props.uploadProgress?.fileIndex}/
{props.uploadProgress?.fileCount}
...
</span>
)}
{props.uploadProgress?.total === 0 ? undefined : (
<span>
{props.uploadProgress?.fileProgress.toFixed(0)}%
</span>
)}
</div>
</div>
</div>
@ -147,54 +200,66 @@ const MessageBar: Component<IMessageBarProps> = (props: IMessageBarProps) => {
return (
<>
{getAttachmentOpen() ? attachmentHtml() : undefined}
<div class="absolute w-full bottom-0 p-4 z-10">
<div class="absolute h-16 w-full pr-8">
{getAttachmentOpen()
? props.uploadProgress
? uploadingHtml()
: attachmentHtml()
: undefined}
<div class="absolute w-full bottom-0 z-10">
<div class="absolute h-24 w-full p-4">
<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 class="p-4">
<div class="bg-stone-800/25 h-16 shadow-bar p-2 flex flex-row gap-2 rounded-full">
<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}
class={`group dropdown dropdown-top ${getDropdownOpen() ? "" : "dropdown-close"}`}
>
<div class="w-6">
<PlusIcon />
<div
tabindex="0"
role="button"
class={`btn ${props.uploadProgress ? "btn-disabled" : ""} btn-neutral bg-stone-950/50 backdrop-blur-lg w-12 p-0 h-full rounded-full transition-transform ${getDropdownOpen() ? "rotate-45" : ""}`}
onClick={() =>
props.uploadProgress
? undefined
: 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>
<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>
<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}
disabled={props.uploadProgress !== undefined}
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"
disabled={props.uploadProgress !== undefined}
onClick={onSend}
>
<div class="w-5">
<UpIcon />
</div>
</button>
</div>
</div>
</div>
</>

View file

@ -3,15 +3,30 @@ import { IconParameters } from "../../icons";
interface IMessageBarProps {
text: string;
uploadProgress?: IUploadProgress;
onChangeText?: (text: string) => void;
onSend?: (files: File[]) => void;
}
interface IUploadProgress {
total: number;
fileName: string;
fileIndex: number;
fileCount: number;
fileProgress: number;
}
type OptionIcon = (props: IconParameters) => JSXElement;
enum MESSAGE_BAR_OPTIONS {
ATTACH_FILES = "Attach files",
ATTACH_LOCATION = "Attach location",
CREATE_POLL = "Create a poll",
}
export { type IMessageBarProps, type OptionIcon, MESSAGE_BAR_OPTIONS };
export {
type IMessageBarProps,
type IUploadProgress,
type OptionIcon,
MESSAGE_BAR_OPTIONS,
};

View file

@ -9,7 +9,7 @@ const RichSettingsItem: Component<IRichSettingsItemProps> = (
if (props.avatar) {
return (
<div class="avatar">
<div class="w-12 rounded-lg">
<div class="min-w-12 w-12 rounded-lg">
<img src={props.avatar} />
</div>
</div>
@ -19,7 +19,7 @@ const RichSettingsItem: Component<IRichSettingsItemProps> = (
if (props.icon) {
return (
<div
class={`bg-stone-700 w-12 rounded-lg p-${props.iconPadding ? props.iconPadding : 1}`}
class={`bg-stone-700 min-w-12 w-12 rounded-lg p-${props.iconPadding ? props.iconPadding : 1}`}
>
<Dynamic component={props.icon} />
</div>
@ -51,7 +51,7 @@ const RichSettingsItem: Component<IRichSettingsItemProps> = (
<input type="checkbox" />
<div class="collapse-title font-semibold flex flex-row items-center gap-4 p-1">
{pictureHtml()}
{props.title}
<span class="truncate">{props.title}</span>
{infoHtml()}
</div>
<div class="collapse-content text-sm font-semibold">

View file

@ -2,9 +2,10 @@ import { state } from "../../store/state";
import { decryptData, encryptData, importKey } from "../crypto";
import { DB_STORE, IDatabaseItem } from "./types";
const DB_NAME = "pulsardb";
const DB_VERSION = 1;
const openDB = async (name: string, store: string): Promise<IDBDatabase> => {
const openDB = async (name: string): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, DB_VERSION);
@ -27,12 +28,16 @@ const openDB = async (name: string, store: string): Promise<IDBDatabase> => {
});
};
const getDB = async (store: string): Promise<IDBDatabase> => {
return await openDB("pulsardb", store);
const getDB = async (): Promise<IDBDatabase> => {
return await openDB(DB_NAME);
};
const deleteDB = async () => {
return indexedDB.deleteDatabase(DB_NAME);
};
const dbSave = async (store: string, item: IDatabaseItem): Promise<void> => {
const db = await getDB(store);
const db = await getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(store, "readwrite");
@ -47,7 +52,7 @@ const dbLoad = async (
store: string,
id: IDBValidKey,
): Promise<IDatabaseItem | undefined> => {
const db = await getDB(store);
const db = await getDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(store, "readonly");
@ -110,6 +115,7 @@ const dbLoadEncrypted = async <T>(
export {
openDB,
getDB,
deleteDB,
dbSave,
dbLoad,
importKey,

View file

@ -27,7 +27,8 @@ import {
generateIv,
} from "../crypto";
const CHUNK_SIZE = 1024 * 1024 * 5;
const MIN_CHUNK_SIZE = 1024 * 1024 * 1;
const MAX_CHUNK_SIZE = 1024 * 1024 * 5;
const uploadUserAvatar = async (filename: string, file: File) => {
const data = await uploadUserAvatarApi({ filename: filename, file: file });
@ -153,7 +154,13 @@ const createAttachment = async (
return;
}
setAttachment({ ...data, fullFile: undefined });
setAttachment({
...data,
filename: filename,
mimetype: mimetype,
size: size,
fullFile: undefined,
});
return data.id;
} catch {
@ -215,6 +222,7 @@ const fetchChunks = async (
attachmentId: string,
chunkCount: number,
mimetype?: string,
onChunkDownloaded?: (chunkIndex: number, totalChunks: number) => void,
) => {
const file: ArrayBuffer[] = [];
@ -229,6 +237,8 @@ const fetchChunks = async (
if (!status) {
decryptionSuccessful = false;
}
onChunkDownloaded?.(i, chunkCount);
} else {
return;
}
@ -242,13 +252,23 @@ const fetchChunks = async (
setAttachmentDecryptionStatus(attachmentId, decryptionSuccessful);
};
const fetchFile = async (communityId: string, attachmentId: string) => {
const fetchFile = async (
communityId: string,
attachmentId: string,
onChunkDownloaded?: (chunkIndex: number, totalChunks: number) => void,
) => {
const attachment = await fetchAttachment(attachmentId, communityId);
if (!attachment) {
return;
}
await fetchChunks(communityId, attachmentId, attachment.chunks.length);
await fetchChunks(
communityId,
attachmentId,
attachment.chunks.length,
undefined,
onChunkDownloaded,
);
const updatedAttachment = state.file.attachments[attachmentId];
if (updatedAttachment?.fullFile && updatedAttachment.decryptionStatus) {
@ -264,7 +284,11 @@ const fetchFile = async (communityId: string, attachmentId: string) => {
}
};
const fetchMedia = async (communityId: string, attachmentId: string) => {
const fetchMedia = async (
communityId: string,
attachmentId: string,
onChunkDownloaded?: (chunkIndex: number, totalChunks: number) => void,
) => {
const attachment = await fetchAttachment(attachmentId, communityId);
if (!attachment) {
return;
@ -279,6 +303,7 @@ const fetchMedia = async (communityId: string, attachmentId: string) => {
attachmentId,
attachment.chunks.length,
attachment.mimetype,
onChunkDownloaded,
);
}
};
@ -328,12 +353,22 @@ const uploadChunks = async (
communityId: string,
attachmentId: string,
file: File,
onChunkUploaded?: (chunkIndex: number, totalChunks: number) => void,
): Promise<boolean> => {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const fileSizeMb = file.size / 1024 / 1024;
let chunkSize = MIN_CHUNK_SIZE;
if (fileSizeMb >= 100) {
chunkSize = MAX_CHUNK_SIZE;
} else if (fileSizeMb >= 20) {
chunkSize = file.size / 20;
}
const totalChunks = Math.ceil(file.size / chunkSize);
for (let index = 0; index < totalChunks; index++) {
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const blob = file.slice(start, end);
const uploaded = await uploadChunk(
@ -345,6 +380,7 @@ const uploadChunks = async (
if (!uploaded) {
return false;
}
onChunkUploaded?.(index, totalChunks);
}
return true;

View file

@ -46,6 +46,7 @@ const fetchMessage = async (id: string, communityId: string) => {
const createMessage = async (
communityId: string,
channelId: string,
ownerId: string,
text: string,
attachments: string[],
replyToId?: string,
@ -56,6 +57,24 @@ const createMessage = async (
}
const [encryptedMessage, iv] = encrypted;
const tempUUID = crypto.randomUUID();
setMessage({
id: tempUUID,
iv: iv,
text: text,
attachments: attachments,
edited: false,
reactions: [],
creationDate: new Date().getTime(),
editHistory: [],
replyToId: replyToId,
channelId: channelId,
ownerId: ownerId,
decryptionStatus: true,
notSentYet: true,
});
const data = await createMessageApi({
text: encryptedMessage,
iv: iv,
@ -67,15 +86,14 @@ const createMessage = async (
if (typeof data.error === "string") {
return;
}
deleteChannelMessage(channelId, tempUUID);
setMessage({
...data,
text: text,
decryptionStatus: true,
});
attachments.forEach((attachmentId) => {
deleteAttachment(attachmentId);
});
};
const updateMessage = async (id: string, text: string, communityId: string) => {

View file

@ -52,11 +52,20 @@ const parseMessage = (data: string): SocketMessage | null => {
const handleMessage = (message: SocketMessage) => {
switch (message.type) {
case SocketMessageTypes.SET_MESSAGE: {
const communityId =
state.channel.channels[message.payload.channelId]?.communityId;
if (!communityId) {
const channel = state.channel.channels[message.payload.channelId];
const communityId = channel?.communityId;
if (!channel || !communityId) {
break;
}
if (channel.messages) {
const exists = Object.values(channel.messages).some(
(msg) => msg?.iv === message.payload.message.iv,
);
if (exists) {
break;
}
}
decryptMessage(
communityId,
message.payload.message.text,

View file

@ -54,6 +54,10 @@ const resetContextMenuOpenId = () => {
setState("app", "contextMenuOpenId", null);
};
const setMembersOpenMobile = (membersOpenMobile: boolean) => {
setState("app", "membersOpenMobile", membersOpenMobile);
};
export {
setAnnouncement,
setHomeOpen,
@ -66,4 +70,5 @@ export {
setCreateInviteOpen,
setContextMenuOpenId,
resetContextMenuOpenId,
setMembersOpenMobile,
};

View file

@ -3,6 +3,7 @@ interface IAppState {
announcement: IAnnouncement;
dialogsOpen: IDialogsOpen;
contextMenuOpenId: string | null;
membersOpenMobile: boolean;
}
interface IAnnouncement {

View file

@ -19,4 +19,15 @@ const deleteMessage = () => {
setState("message", "message", undefined);
};
export { setMessage, deleteMessage };
const deleteChannelMessage = (messageId: string, channelId: string) => {
setState(
"channel",
"channels",
channelId,
"messages",
messageId,
undefined,
);
};
export { setMessage, deleteMessage, deleteChannelMessage };

View file

@ -15,6 +15,7 @@ interface IMessage {
channelId?: string;
creationDate: number;
decryptionStatus?: boolean;
notSentYet?: boolean;
}
export { type IMessageState, type IMessage };

View file

@ -1,7 +1,7 @@
import { createStore } from "solid-js/store";
import { IState } from "./types";
const [state, setState] = createStore<IState>({
const defaultState: IState = {
app: {
homeOpen: true,
announcement: {
@ -18,6 +18,7 @@ const [state, setState] = createStore<IState>({
createInviteOpen: false,
},
contextMenuOpenId: null,
membersOpenMobile: false,
},
auth: {
registerSuccess: undefined,
@ -52,6 +53,12 @@ const [state, setState] = createStore<IState>({
attachments: {},
chunks: {},
},
});
};
export { state, setState };
const [state, setState] = createStore<IState>(defaultState);
const resetState = () => {
setState(defaultState);
};
export { state, setState, resetState };

View file

@ -38,7 +38,6 @@ const AppView: Component = () => {
});
createEffect(() => {
console.log(state.auth.loggedIn);
if (state.auth.loggedIn === false) {
navigate("/login");
} else if (state.auth.loggedIn === true) {

View file

@ -335,7 +335,9 @@ const ChannelView: Component = () => {
};
return (
<div class="bg-stone-900 w-80 shadow-panel z-20 h-full relative">
<div
class={`${getActiveChannel() ? "hidden lg:block" : "block flex-1 lg:flex-none"} lg:w-80 bg-stone-900 shadow-panel z-20 h-full relative`}
>
<ContextMenu
visible={listMenu.getVisible()}
position={listMenu.getPos()}

View file

@ -6,7 +6,7 @@ import {
type Component,
} from "solid-js";
import { ChannelBar } from "../../components/ChannelBar";
import { MessageBar } from "../../components/MessageBar";
import { IUploadProgress, MessageBar } from "../../components/MessageBar";
import { Message } from "../../components/Message";
import { state } from "../../store/state";
import { getActiveChannel, IChannel, setText } from "../../store/channel";
@ -20,6 +20,7 @@ import {
import { IMessage } from "../../store/message";
import {
createAttachment,
fetchMedia,
finishAttachment,
uploadChunks,
} from "../../services/file";
@ -38,6 +39,7 @@ import {
} from "../../icons";
import { getUserBestRoleId } from "../../services/role";
import { IRole } from "../../store/role";
import { getLoggedInUser } from "../../store/user";
const ChatView: Component = () => {
const menu = useContextMenu<string>("chat-message-menu");
@ -45,6 +47,8 @@ const ChatView: Component = () => {
const [getEditingMessages, setEditingMessages] = createSignal<Set<string>>(
new Set(),
);
const [getAttachmentUploadProgress, setAttachmentUploadProgress] =
createSignal<IUploadProgress | undefined>();
const editingMenuItems = createMemo<IContextMenuItem[]>(() => {
if (getEditingMessages().has(menu.getData() ?? "")) {
@ -180,7 +184,8 @@ const ChatView: Component = () => {
const channel = channelInfo();
const community = getActiveCommunity();
if (!channel?.id || !community?.id) {
const owner = getLoggedInUser();
if (!channel?.id || !community?.id || !owner?.id) {
return;
}
@ -192,35 +197,72 @@ const ChatView: Component = () => {
}
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(
if (files.length > 0) {
setAttachmentUploadProgress({
total: 0,
fileName: files[0].name,
fileIndex: 0,
fileCount: 0,
fileProgress: 0,
});
for (const [index, file] of files.entries()) {
const updateProgress = (
chunkIndex: number,
totalChunks: number,
) => {
const totalProgress = index / files.length;
const fileProgress = (chunkIndex + 1) / totalChunks;
setAttachmentUploadProgress({
total:
(totalProgress + fileProgress / files.length) * 100,
fileName: file.name,
fileIndex: index + 1,
fileCount: files.length,
fileProgress: fileProgress * 100,
});
};
const attachmentId = await createAttachment(
community.id,
attachmentId,
file,
file.name,
file.type,
file.size,
);
if (uploaded) {
await finishAttachment(attachmentId);
if (attachmentId) {
attachmentIds.push(attachmentId);
const uploaded = await uploadChunks(
community.id,
attachmentId,
file,
updateProgress,
);
if (uploaded) {
await finishAttachment(attachmentId);
}
}
}
setAttachmentUploadProgress();
}
setText(channel.id, "");
createMessage(
await createMessage(
community.id,
channel.id,
owner.id,
text ?? "",
attachmentIds,
undefined,
);
attachmentIds.forEach((attachmentId) => {
fetchMedia(community.id, attachmentId);
});
};
const onRemoveMessage = (messageId: string | undefined) => {
@ -316,16 +358,11 @@ const ChatView: Component = () => {
return (
<Message
messageId={message.id ?? ""}
message={message.text}
userId={message.ownerId}
message={message}
username={user.nickname ?? user.username ?? ""}
color={bestRole?.color}
avatar={getUserAvatarUrl(user.avatar)}
attachments={message.attachments}
time={message.creationDate}
plain={isPlainMessage(message, allMessages[index - 1])}
decryptionStatus={message.decryptionStatus ?? false}
editing={getEditingMessages().has(message.id)}
onProfileClick={onProfileClick}
onMessageRightClick={onMessageRightClick}
@ -335,7 +372,9 @@ const ChatView: Component = () => {
};
return (
<div class="bg-stone-800 flex-1 z-0 relative">
<div
class={`${getActiveChannel() && !state.app.membersOpenMobile ? "block" : "hidden lg:block"} bg-stone-800 flex-1 z-0 relative`}
>
<ContextMenu
visible={menu.getVisible()}
position={menu.getPos()}
@ -357,6 +396,7 @@ const ChatView: Component = () => {
{channelInfo() ? (
<MessageBar
text={channelInfo()?.text ?? ""}
uploadProgress={getAttachmentUploadProgress()}
onChangeText={onChangeMessageText}
onSend={onSendMessage}
/>

View file

@ -70,14 +70,14 @@ const CommunitySettingsModalView: Component<
{getActiveCommunity()?.name} Settings
</h3>
<div class="divider"></div>
<div class="flex flex-row gap-4 px-4">
<div class="flex flex-col lg:flex-row gap-4 px-4 items-center lg:items-stretch">
<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>
<div class="divider divider-vertical lg:divider-horizontal"></div>
{getCurrentPage()}
</div>
</div>

View file

@ -244,8 +244,8 @@ const CommunitySettingsChannelsPage: Component = () => {
};
return (
<div class="flex-1 flex flex-row gap-4">
<div class="flex flex-col gap-2">
<div class="flex-1 flex flex-col lg:flex-row gap-4 w-full">
<div class="flex flex-col gap-2 items-center lg:items-stretch">
<h3 class="text-lg font-bold text-center mb-4">Channels</h3>
{channels().map(mapChannel)}
{channels().length > 0 ? (
@ -261,7 +261,7 @@ const CommunitySettingsChannelsPage: Component = () => {
Add new Channel
</button>
</div>
<div class="divider divider-horizontal"></div>
<div class="divider divider-vertical lg:divider-horizontal"></div>
<div class="flex-1">{optionsHtml()}</div>
</div>
);

View file

@ -221,12 +221,12 @@ const CommunitySettingsMembersPage: Component = () => {
};
return (
<div class="flex-1 flex flex-row gap-4">
<div class="flex flex-col gap-2">
<div class="flex-1 flex flex-col lg:flex-row gap-4 w-full">
<div class="flex flex-col gap-2 items-center lg:items-stretch">
<h3 class="text-lg font-bold text-center mb-4">Members</h3>
{members().map(mapMember)}
</div>
<div class="divider divider-horizontal"></div>
<div class="divider divider-vertical lg:divider-horizontal"></div>
<div class="flex-1">{optionsHtml()}</div>
</div>
);

View file

@ -285,8 +285,8 @@ const CommunitySettingsRolesPage: Component = () => {
};
return (
<div class="flex-1 flex flex-row gap-4">
<div class="flex flex-col gap-2">
<div class="flex-1 flex flex-col lg:flex-row gap-4 w-full">
<div class="flex flex-col gap-2 items-center lg:items-stretch">
<h3 class="text-lg font-bold text-center mb-4">Roles</h3>
{roles().map(mapRole)}
{roles().length > 0 ? <div class="divider"></div> : undefined}
@ -300,7 +300,7 @@ const CommunitySettingsRolesPage: Component = () => {
Add new Role
</button>
</div>
<div class="divider divider-horizontal"></div>
<div class="divider divider-vertical lg:divider-horizontal"></div>
<div class="flex-1">{optionsHtml()}</div>
</div>
);

View file

@ -17,7 +17,7 @@ import {
getCommunityAvatarUrl,
removeCommunityMember,
} from "../../services/community";
import { resetActiveChannel } from "../../store/channel";
import { getActiveChannel, resetActiveChannel } from "../../store/channel";
import {
ContextMenu,
IContextMenuItem,
@ -108,7 +108,9 @@ const CommunityView: Component = () => {
};
return (
<div class="flex flex-col bg-stone-950 w-16 h-full shadow-panel z-30 p-2 gap-2">
<div
class={`${getActiveChannel() ? "hidden lg:flex" : "flex"} flex flex-col bg-stone-950 w-16 h-full shadow-panel z-30 p-2 gap-2`}
>
<ContextMenu
visible={menu.getVisible()}
position={menu.getPos()}

View file

@ -4,8 +4,9 @@ import { HomeCard } from "../../components/HomeCard";
import { setAddCommunityOpen, setSettingsOpen } from "../../store/app";
import { removeSession } from "../../services/session";
import { useNavigate } from "@solidjs/router";
import { state } from "../../store/state";
import { resetState, state } from "../../store/state";
import { resetLoggedIn, resetAuthSession } from "../../store/auth";
import { deleteDB } from "../../services/database";
const HomeView: Component = () => {
const navigate = useNavigate();
@ -26,12 +27,15 @@ const HomeView: Component = () => {
resetLoggedIn();
resetAuthSession();
resetState();
deleteDB();
navigate("/");
};
return (
<div class="flex-1 flex flex-row bg-stone-900 shadow-panel z-0">
<div class="flex-1 flex flex-row items-center justify-center gap-8">
<div class="bg-stone-900 flex-1 flex flex-col lg:flex-row items-center justify-center">
<div class="flex flex-col lg:flex-row items-center justify-start gap-8 p-8 w-full lg:w-fit overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800">
<HomeCard
title="Find a Community"
description="Find or create a new Community to chat"

View file

@ -1,5 +1,5 @@
import { createEffect, createSignal, onMount, type Component } from "solid-js";
import { state } from "../../store/state";
import { setState, state } from "../../store/state";
import { useNavigate } from "@solidjs/router";
import { fetchLogin, fetchRefresh } from "../../services/auth";

View file

@ -17,11 +17,12 @@ import {
IContextMenuItem,
useContextMenu,
} from "../../components/ContextMenu";
import { DownIcon, TrashIcon, UpIcon } from "../../icons";
import { DownIcon, LeftIcon, TrashIcon, UpIcon } from "../../icons";
import { getActiveCommunity } from "../../store/community";
import { IUser } from "../../store/user";
import { IRole } from "../../store/role";
import { getUserBestRoleId } from "../../services/role";
import { setMembersOpenMobile } from "../../store/app";
const MemberView: Component = () => {
const roleMenu = useContextMenu<string>("channel-role-menu");
@ -243,7 +244,9 @@ const MemberView: Component = () => {
};
return (
<div class="bg-stone-900 w-64 shadow-panel z-20 relative">
<div
class={`${state.app.membersOpenMobile ? "block w-full" : "hidden "} lg:block bg-stone-900 lg:w-64 shadow-panel z-20 relative`}
>
<ContextMenu
visible={roleMenu.getVisible()}
position={roleMenu.getPos()}
@ -257,7 +260,13 @@ const MemberView: Component = () => {
items={memberMenuItems}
/>
<div class="h-full">
<ul class="h-full list flex flex-col p-2 gap-1 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800">
<div
class="block lg:hidden ml-1 w-16 p-5 cursor-pointer"
onClick={() => setMembersOpenMobile(false)}
>
<LeftIcon />
</div>
<ul class="list flex flex-col p-2 gap-1 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800">
{roles().map(mapRole)}
</ul>
</div>

View file

@ -47,14 +47,14 @@ const SettingsModalView: Component<ISettingsModalViewProps> = (
<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">
<div class="flex flex-col lg:flex-row gap-4 px-4 items-center lg:items-stretch">
<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>
<div class="divider divider-vertical lg:divider-horizontal"></div>
{getCurrentPage()}
</div>
</div>

View file

@ -146,12 +146,12 @@ const SettingsCommunitiesPage: Component = () => {
};
return (
<div class="flex-1 flex flex-row gap-4">
<div class="flex flex-col gap-2">
<div class="flex-1 flex flex-col lg:flex-row gap-4 w-full">
<div class="flex flex-col gap-2 items-center lg:items-stretch">
<h3 class="text-lg font-bold text-center mb-4">Communities</h3>
{communityIds().map(mapCommunity)}
</div>
<div class="divider divider-horizontal"></div>
<div class="divider divider-vertical lg:divider-horizontal"></div>
<div class="flex-1">{optionsHtml()}</div>
</div>
);

View file

@ -6,11 +6,12 @@ import {
type Component,
} from "solid-js";
import { fetchUserSessions } from "../../../../services/user";
import { state } from "../../../../store/state";
import { resetState, state } from "../../../../store/state";
import { RichSettingsItem } from "../../../../components/RichSettingsItem";
import { DeviceIcon, MinusIcon, TrashIcon } from "../../../../icons";
import { removeSession } from "../../../../services/session";
import { useNavigate } from "@solidjs/router";
import { deleteDB } from "../../../../services/database";
const SettingsSessionPage: Component = () => {
const [getSelectedSessionId, setSelectedSessionId] = createSignal<
@ -43,6 +44,9 @@ const SettingsSessionPage: Component = () => {
removeSession(sessionId);
if (sessionId === state.auth.session?.id) {
resetState();
deleteDB();
navigate("/");
}
};