This commit is contained in:
2026-04-09 11:23:26 +02:00
parent c5bc817efe
commit 9baab3d3bc
18 changed files with 489 additions and 37 deletions

8
package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@chatenium/chatenium-sdk": "^1.0.6",
"@chatenium/chatenium-sdk": "^1.0.8",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"@taiga-ui/addon-charts": "^5.1.0",
@@ -987,9 +987,9 @@
}
},
"node_modules/@chatenium/chatenium-sdk": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@chatenium/chatenium-sdk/-/chatenium-sdk-1.0.6.tgz",
"integrity": "sha512-lNrgIiXJWYc7KiQCtzWqgLQ0RpEoRWU2Z/GVEjcMyMlCMCX6Lu0g0cKj/8mREhk7kbJUAEcHnB18w5VjNicbIg==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@chatenium/chatenium-sdk/-/chatenium-sdk-1.0.8.tgz",
"integrity": "sha512-avJ61UPEk6GQ6+0fbA9Tl2VZU7O7RE/evAXIsTxA0S0oQzltWFA4k0ttPCPw8LLEpNNQCoQk/2dd0l2+y75t4Q==",
"dependencies": {
"@faker-js/faker": "^10.4.0",
"axios": "^1.14.0",

View File

@@ -18,7 +18,7 @@
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@chatenium/chatenium-sdk": "^1.0.6",
"@chatenium/chatenium-sdk": "^1.0.8",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"@taiga-ui/addon-charts": "^5.1.0",

View File

@@ -27,6 +27,15 @@
}
}
}
},
"elements": {
"messageBox": {
"placeholder": "Type a message...",
"uplDrag": {
"upload": "Drop here to upload",
"transfer": "Drop here to transfer"
}
}
}
}
}

View File

@@ -2,6 +2,7 @@
display: grid;
grid-template-columns: 350px minmax(0, 1fr);
height: 100svh;
overflow: hidden;
#chatnav {
display: grid;
@@ -36,7 +37,6 @@
}
main {
padding-top: 65px;
background: var(--tui-background-base-alt);
border-radius: 20px 0 0 20px;
margin: 10px 0 10px 10px;

View File

@@ -7,6 +7,7 @@ import {LoadStatus, ServiceManager} from '../service-manager';
import {IndexedDB} from '../storage/indexed-db';
import {DmList} from './dm-list/dm-list';
import {JsonPipe} from '@angular/common';
import {WebSocketHandler} from '@chatenium/chatenium-sdk/core/webSocketHandler';
@Component({
selector: 'app-chat',
@@ -30,7 +31,9 @@ export class Chat implements OnInit {
async ngOnInit() {
this.indexedDb.openDatabase().then(async () => {
this.serviceManager.currentSession.set(await this.serviceManager.sessionManager.loadPreferredSession())
const session = await this.serviceManager.sessionManager.loadPreferredSession()
this.serviceManager.currentSession.set(session)
await WebSocketHandler.getInstance().connect(session.userData.userid, session.token)
})
}

View File

@@ -1,13 +1,13 @@
@defer (when store) {
<navbar>
<div class="items-left">
<oimg [src]="store.chatData.pfp" height="50px" width="50px" [radius]="15"></oimg>
<oimg [src]="store.chatData().pfp" height="50px" width="50px" [radius]="15"></oimg>
<div class="chat-data">
@if (store.chatData.displayName == "") {
<span class="main-name">{{'@'+store.chatData.username}}</span>
@if (store.chatData().displayName == "") {
<span class="main-name">{{'@'+store.chatData().username}}</span>
} @else {
<span class="main-name">{{store.chatData.displayName}}</span>
<span class="alt-name">{{'@'+store.chatData.username}}</span>
<span class="main-name">{{store.chatData().displayName}}</span>
<span class="alt-name">{{'@'+store.chatData().username}}</span>
}
</div>
</div>
@@ -17,11 +17,10 @@
<tui-icon icon="@tui.phone"/>
</button>
</div>
{{chatid}}
</navbar>
<main>
<messages [messages]="store.messages()"/>
</main>
<message-box/>
<message-box [viewModel]="store.messageBox"/>
}

View File

@@ -1,5 +1,5 @@
:host {
height: 100%;
height: 95svh;
display: grid;
grid-template-rows: 70px minmax(0, 1fr) auto;

View File

@@ -1,4 +1,4 @@
import {Component, inject, OnInit} from '@angular/core';
import {Component, inject, OnInit, signal} from '@angular/core';
import {DmStorage, ServiceManager} from '../../service-manager';
import {ActivatedRoute} from '@angular/router';
import {DMService} from '@chatenium/chatenium-sdk/services/dmService';
@@ -7,6 +7,10 @@ import {Navbar} from '../elements/navbar/navbar';
import {Oimg} from '../elements/oimg/oimg';
import {TuiButton, TuiIcon} from '@taiga-ui/core';
import {MessageBox} from '../elements/message-box/message-box';
import {Messages} from '../elements/messages/messages';
import {Chat} from '@chatenium/chatenium-sdk/domain/chatService.schema';
import {Message} from '@chatenium/chatenium-sdk/domain/dmService.schema';
import {MessageBoxViewModel} from '../elements/message-box/message-box-viewmodel';
@Component({
selector: 'app-dm',
@@ -15,7 +19,8 @@ import {MessageBox} from '../elements/message-box/message-box';
Oimg,
TuiButton,
TuiIcon,
MessageBox
MessageBox,
Messages
],
templateUrl: './dm.html',
styleUrl: './dm.scss',
@@ -31,14 +36,23 @@ export class Dm implements OnInit {
return this.serviceManager.dmServices()[this.chatid]
}
async sendMessage(message: string) {
await this.store.service.sendMessage(message)
}
onWsListen(action: string, message: string) {
console.log(action, message)
}
ngOnInit() {
this.route.params.subscribe(params => {
this.route.params.subscribe(async params => {
const chatid = params['chatid']
this.chatid = chatid
console.log(this.serviceManager.chats())
const session = this.serviceManager.currentSession();
const chatData = this.serviceManager.chats().find(chat => chat.chatid == chatid)
// Setup storage
if (!this.serviceManager.dmServices()[chatid] && session != null && chatData != null) {
this.serviceManager.dmServices()[chatid] = {
service: new DMService(
@@ -46,11 +60,20 @@ export class Dm implements OnInit {
session.token,
chatid,
this.indexedDb.getApi(),
() => {}
(action, data) => {
this.onWsListen(action, data)
}
),
chatData: chatData
chatData: signal<Chat>(chatData),
messages: signal<Message[]>([]),
messageBox: new MessageBoxViewModel(
(msg) => this.sendMessage(msg),
)
}
}
this.store.messages.set(await this.serviceManager.dmServices()[chatid].service.get())
await this.store.service.joinWebSocketRoom()
})
}
}

View File

@@ -0,0 +1,11 @@
import {signal} from '@angular/core';
export class MessageBoxViewModel {
onMessageSend: (message: string) => void
constructor(onMessageSend: (message: string) => void) {
this.onMessageSend = onMessageSend
}
message = signal<string>("")
}

View File

@@ -1,7 +1,36 @@
<div id="message-box">
<div></div>
<div id="dragOverlay" tuiGroup orientation="vertical" [class.show]="isDraggingOverWindow">
<div class="method">
<div class="icon-holder">
<tui-icon icon="@tui.file-up"/>
</div>
<span>{{ "chat.elements.messageBox.uplDrag.upload"|translate }}</span>
</div>
<div class="method">
<div class="icon-holder">
<tui-icon icon="@tui.cloud-sync"/>
</div>
<span>{{ "chat.elements.messageBox.uplDrag.transfer"|translate }}</span>
</div>
</div>
<div id="message-box" [style]="'border-radius:'+messageBoxRadius+'px;'">
<div class="items-left">
<button tuiButton appearance="flat">
<tui-icon icon="@tui.file-up"/>
</button>
<button tuiButton appearance="flat">
<tui-icon icon="@tui.cloud-sync"/>
</button>
</div>
<div class="items-middle">
<textarea></textarea>
<textarea [style]="'height:'+textareaHeight+'px;'" #message (input)="onTextAreaInput(message)" [(ngModel)]="viewModel().message"></textarea>
<span class="placeholder"
[class.hidden]="message.value != ''">{{ "chat.elements.messageBox.placeholder"|translate }}</span>
</div>
<div class="items-right">
<button tuiButton appearance="flat" (click)="viewModel().onMessageSend(message.value)">
<tui-icon icon="@tui.send"/>
</button>
</div>
<div></div>
</div>

View File

@@ -3,27 +3,117 @@
display: flex;
justify-content: center;
#dragOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out;
&.show {
opacity: 1;
pointer-events: all;
}
.method {
width: 800px;
height: 200px;
background: var(--tui-background-accent-1-hover);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 15px;
.icon-holder {
border-radius: 50%;
background: var(--tui-background-accent-1);
padding: 20px;
tui-icon {
font-size: 50px;
}
}
span {
font-size: 20px;
font-weight: 600;
}
}
}
#message-box {
transition: all 0.2s ease-in-out;
width: 60%;
background: var(--tui-background-base-alt);
height: 75px;
border-radius: 200px;
min-height: 75px;
max-height: 200px;
border: 2px solid var(--tui-border-normal);
display: grid;
grid-template-columns: 10% 80% 10%;
grid-template-columns: 85px 1fr 100px;
align-items: center;
padding: 0 10px;
.items-left, .items-middle, .items-right {
display: flex;
align-items: center;
}
.items-left, .items-right {
button {
height: 30px;
width: 30px;
tui-icon {
font-size: 20px;
}
}
}
.items-middle {
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 10px;
}
position: relative;
textarea {
width: 100%;
height: 100%;
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--tui-text-01);
font-size: 16px;
z-index: 1;
}
.placeholder {
position: absolute;
font-size: 16px;
height: 25px;
top: 2px;
left: 2px;
color: gray;
transition: all 0.2s ease-in-out;
&.hidden {
margin-left: 10px;
opacity: 0;
}
}
}
.items-right {
display: flex;
justify-content: end;
}
}
}

View File

@@ -1,12 +1,59 @@
import { Component } from '@angular/core';
import {TuiAppearance} from '@taiga-ui/core';
import {Component, HostListener, inject, input} from '@angular/core';
import {TuiAppearance, TuiButton, TuiGroup, TuiIcon, TuiScrollbarDirective} from '@taiga-ui/core';
import {TranslatePipe} from '@ngx-translate/core';
import {ServiceManager} from '../../../service-manager';
import {MessageBoxViewModel} from './message-box-viewmodel';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'message-box',
imports: [
TuiAppearance
TuiAppearance,
TranslatePipe,
TuiScrollbarDirective,
TuiGroup,
TuiIcon,
TuiButton,
FormsModule
],
templateUrl: './message-box.html',
styleUrl: './message-box.scss',
})
export class MessageBox {}
export class MessageBox {
viewModel = input.required<MessageBoxViewModel>()
textareaHeight = 25
messageBoxRadius = 200
private dragCounter = 0;
isDraggingOverWindow = false;
@HostListener('window:dragenter', ['$event'])
onDragEnter(event: DragEvent) {
event.preventDefault();
this.dragCounter++;
if (this.dragCounter === 1) {
this.isDraggingOverWindow = true;
console.log("Drag entered the window area");
}
}
@HostListener('window:dragleave', ['$event'])
onDragLeave(event: DragEvent) {
event.preventDefault();
this.dragCounter--;
if (this.dragCounter === 0) {
this.isDraggingOverWindow = false;
console.log("Drag left the window completely");
}
}
onTextAreaInput(e: HTMLTextAreaElement) {
const calculatedHeight = e.value.split(/\r?\n/).length * 25
const calculatedRadius = (200 - this.textareaHeight)
this.textareaHeight = calculatedHeight > 180 ? 180 : calculatedHeight
this.messageBoxRadius = calculatedRadius < 30 ? 30 : calculatedRadius
}
}

View File

@@ -0,0 +1,19 @@
@if (serviceManager.currentSession != null) {
@for (message of messages(); track message.msgid; let i = $index) {
<div
class="message"
[class.author]="message.author == serviceManager.currentSession()!.userData.userid"
[class.chained_start]="isMessageStartOfChain(i)"
[class.chained_middle]="isMessageMiddleInChain(i)"
[class.chained_end]="isMessageEndOfChain(i)"
>
<div class="above">
<span>{{message.sent_at.T * 1000 | date: 'HH:mm'}}</span>
</div>
<div class="bubble">
<span class="message-text">{{message.message}}</span>
</div>
<div class="below"></div>
</div>
}
}

View File

@@ -0,0 +1,69 @@
:host {
padding: 10px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: scroll;
.message {
display: flex;
flex-direction: column;
&.author {
align-items: end;
.bubble {
background: var(--tui-background-accent-1-hover);
}
}
&.chained_start {
margin-top: 10px;
.above, .below {
display: block;
}
.above {
padding: 2px;
}
.bubble {
border-radius: 25px 10px 10px 10px !important;
}
}
&.chained_middle {
.bubble {
border-radius: 10px !important;
}
}
&.chained_end {
.bubble {
border-radius: 10px 10px 10px 25px !important;
}
}
.above, .below {
color: gray;
opacity: 70%;
display: none;
}
.bubble {
background: var(--tui-background-neutral-2);
max-width: 350px;
min-width: 250px;
min-height: 40px;
display: flex;
align-items: center;
padding: 5px;
.message-text {
white-space: none;
overflow-wrap: anywhere;
}
}
}
}

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Messages } from './messages';
describe('Messages', () => {
let component: Messages;
let fixture: ComponentFixture<Messages>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Messages],
}).compileComponents();
fixture = TestBed.createComponent(Messages);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,109 @@
import {Component, inject, input} from '@angular/core';
import {Message} from '@chatenium/chatenium-sdk/domain/dmService.schema';
import {Message as NetworkMessage} from '@chatenium/chatenium-sdk/domain/textChannelService.schema'
import {ServiceManager} from '../../../service-manager';
import {DatePipe} from '@angular/common';
@Component({
selector: 'messages',
imports: [
DatePipe
],
templateUrl: './messages.html',
styleUrl: './messages.scss',
})
export class Messages {
serviceManager = inject(ServiceManager)
messages = input<Message[] | NetworkMessage[]>([])
/**
* Helps code readability by specifying what type of messages are being processed.
* Example: messageAsDm(message).author == userid -- We are specifying that we are handling dm messages
* @param message
*/
messageAsDm(message: Message | NetworkMessage) {
return message == undefined ? <Message>{author: "no"} : message as Message
}
/**
* Helps code readability by specifying what type of messages are being processed
Example: messageAsNetwork(message).author.userid == userid -- We are specifying that we are handling network messages
* @param message
*/
messageAsNetwork(message: Message | NetworkMessage) {
return message == undefined ? <NetworkMessage>{author: {userid: "no"}} : message as NetworkMessage
}
isMessageStartOfChain(i: number) {
const message = this.messages()[i];
if (!message) return false;
const prev = this.messages()[i - 1];
// is author == last msg's author
if (prev) {
if (
(typeof message.author === 'string' && this.messageAsDm(prev).author !== this.messageAsDm(message).author) &&
this.messageAsNetwork(prev).author.userid !== this.messageAsNetwork(message).author.userid
) {
return true;
}
// same author but time gap between this and prev msg
if (
Math.abs(message.sent_at.T - prev.sent_at.T) >= 60
) {
return true;
}
}
return i === 0;
}
isMessageMiddleInChain(i: number) {
const message = this.messages()[i];
if (!message || i == 0) return false;
const prev = this.messages()[i - 1];
// message must be from the same author and have little time gap between
return (
(
(typeof message.author === 'string' && this.messageAsDm(prev).author == this.messageAsDm(message).author) ||
this.messageAsNetwork(prev).author.userid == this.messageAsNetwork(message).author.userid
) &&
Math.abs(message.sent_at.T - prev.sent_at.T) <= 60
);
}
isMessageEndOfChain(i: number) {
// prevent false positive
if (this.isMessageStartOfChain(i)) return false;
const message = this.messages()[i];
if (!message) return false;
const next = this.messages()[i + 1];
// First condition: next author is different
if (next) {
if (
(typeof message.author === 'string' && this.messageAsDm(next).author != this.messageAsDm(message).author) ||
this.messageAsNetwork(next).author.userid != this.messageAsNetwork(message).author.userid
) {
return true;
}
// same author but time gap between this and next msg
if (
Math.abs(message.sent_at.T - next.sent_at.T) >= 60
) {
return true;
}
}
// last message is always end
return i + 1 === this.messages().length;
}
}

View File

@@ -1,4 +1,4 @@
import {inject, Injectable, signal} from '@angular/core';
import {inject, Injectable, Signal, signal, WritableSignal} from '@angular/core';
import {IndexedDB} from './storage/indexed-db';
import {Keyring} from './storage/keyring';
import {KeyValue} from './storage/key-value';
@@ -7,6 +7,8 @@ import {Session} from '@chatenium/chatenium-sdk/domain/sessionManager.schema';
import {ChatService} from '@chatenium/chatenium-sdk/services/chatService';
import {Chat} from '@chatenium/chatenium-sdk/domain/chatService.schema';
import {DMService} from '@chatenium/chatenium-sdk/services/dmService';
import {Message} from '@chatenium/chatenium-sdk/domain/dmService.schema';
import {MessageBoxViewModel} from './chat/elements/message-box/message-box-viewmodel';
@Injectable({
providedIn: 'root',
@@ -35,5 +37,7 @@ export enum LoadStatus {
export interface DmStorage {
service: DMService
chatData: Chat
messages: WritableSignal<Message[]>
chatData: WritableSignal<Chat>
messageBox: MessageBoxViewModel
}

View File

@@ -1,4 +1,22 @@
/* You can add global styles to this file, and also import other style files */
.drag-active * {
pointer-events: none;
}
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--tui-background-neutral-2-pressed);
border-radius: 10px;
}
body {
margin: 0;
padding: 0;