6 Commits

Author SHA1 Message Date
0dc1e15e59 3.0 Beta 6 2026-04-14 17:51:02 +02:00
931ac91526 3.0 Beta 5 2026-04-13 15:14:24 +02:00
c56b0aab8a Started implementing Chatenium Pictures 2026-04-12 17:40:51 +02:00
62bc82f158 3.0 Beta 4 2026-04-11 17:23:19 +02:00
7e5ea20409 3.0 Beta 3 2026-04-11 13:22:56 +02:00
a01b5347d6 3.0 Beta 2 2026-04-10 21:57:29 +02:00
109 changed files with 3504 additions and 511 deletions

View File

@@ -42,15 +42,16 @@
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumError": "100MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumError": "100MB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
},
"development": {
"optimization": false,

30
ngsw-config.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

660
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,11 @@
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@chatenium/chatenium-sdk": "^1.1.3",
"@angular/service-worker": "^21.2.0",
"@chatenium/chatenium-sdk": "^1.1.10",
"@fortawesome/angular-fontawesome": "^4.0.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"@taiga-ui/addon-charts": "^5.1.0",

BIN
public/Kinn-Book.ttf Normal file

Binary file not shown.

BIN
public/Kinn-Heavy.ttf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,4 +1,82 @@
{
"version": "3.0 Beta 6 (April 14, 2026)",
"ok": "Ok",
"back": "Back",
"aChatProgram": "A messaging platform that you can trust.",
"fast": "Fast",
"secure": "Secure",
"independent": "Independent",
"scrollDownForMore": "Scroll down for more",
"whatIsChtn": "Chatenium is a chat platform aiming to provide a secure, well integrated fast chatting experience across any devices. You can create a new account for free and start chatting and broadcasting messages.",
"updating": "Updating...",
"home": {
"chtn": "Chatenium ",
"chtnIs": "Chatenium is ",
"help": "Help",
"blog": "Blog",
"apiSpecs": "API specifications",
"adaptsToYou": "adapts to you",
"adaptsToYouDesc": "We actively work on bringing Chatenium to every device, but not the fast way, the good way by developing a native application for each platform.",
"chtnOnWeb": "Chatenium On Web",
"chtnOnWebDesc": "Built for maximum compatibility. The Web will work on every device you have.",
"chtnOnAndroid": "Chatenium On Android™",
"chtnOnAndroidDesc": "Built to work beautifully with your Android™ device. Blends in beautifully with your Material You theme.",
"chtnOnApple": "Chatenium On Apple®",
"chtnOnAppleDesc": "Built with the latest Liquid Glass design to blend in and work flawlessly across all your Apple® devices.",
"chtnOnWindows": "Chatenium On Windows®",
"chtnOnWindowsDesc": "We are also expanding our native apps to Microsoft Windows®, because using a web app is resource intensive and inferior to a native application. So we built a fully native Windows® application using WinUI3 + C#.",
"chtnOnLinux": "Chatenium On Linux",
"chtnOnLinuxDesc": "Built on the latest GNOME Adwaita design language to beautifully blend into the GNOME desktop environment on Linux.",
"chtnEcho": "Chatenium Echo",
"chtnEchoDesc": "Built on the GNOME GTK design language to beautifully blend into any GTK based desktop environment on Linux.",
"chtnReson": "Chatenium Reson",
"chtnResonDesc": "Built on the KDE Kirigami design language to beautifully blend into KDE or any other Qt based desktop environment on Linux.",
"tba": "To be announced",
"secure": "secure",
"secureDesc": "Chatenium uses the latest algorithms to protect your data from any third-parties.",
"secureCdn": "Encrypted CDN",
"secureCdnDesc": "All uploaded media is encrypted and never accessed by anyone outside your chat. Not even by us. All deleted media are permanently removed from our servers and databases.",
"encryptedText": "Encrypted messages",
"encryptedTextDesc": "Any message that is sent to any DM chat or channel is encrypted and stored securely in our databases. We do not share your messages with any third-parties.",
"zeroDataCollection": "Zero data collection",
"zeroDataCollectionDesc": "Our policies allow zero data collection. We do not collect any data from our clients.",
"secureCalling": "Secure calls",
"secureCallingDesc": "When using Chatenium Call, all audio and video tracks are sent to our servers for secure distribution. No audio or video is ever recorded by our servers under any circumstances.",
"zeroDigitalFootprint": "Zero digital footprint",
"zeroDigitalFootprintDesc": "We make it easy to delete your account from our services and we make sure to remove all data made by you.",
"underDevelopment": "Under development",
"devPreview": "Developer Preview",
"weAreExcitedFor": "We are excited for ",
"you": "you",
"weAreExcitedForYouDesc": "Our community awaits you with love. We are independent, so you can express your true feelings without getting censored.",
"enterChtnOnWeb": "Enter Chatenium On Web",
"enterChtnOnWebDesc": "Register your account here are access the latest experimental features we can offer. The web is mobile-friendly and offers offline mode.",
"downloadChtnOnAndroid": "Get Chatenium On Android",
"downloadChtnOnAndroidDesc": "We are preparing a Google Play release right now. Don't worry, you can get access to .apk releases in the official 'Chatenium' network.",
"downloadChtnOnApple": "Get Chatenium On Apple",
"downloadChtnOnAppleDesc": "We are preparing an App Store release right now. Don't worry, you can get access to .ipa (.dmg soon) releases in the official 'Chatenium' network.",
"storeReleaseSoon": "Store release coming soon...",
"closedAlpha": "Closed alpha",
"openBeta": "Beta",
"openBetaDesc": "Participate in public beta testing to help us create the best app experience!",
"closedAlphaDesc": "Great news! You are eligible to participate in the testing program. All we need is your e-mail address.",
"alphaRequestedSuccessfully": "Request sent successfully! We will get back to you later.",
"alphaRequestAlreadySent": "You already requested an alpha access! We will get back to you later.",
"alphaRequestError": "Something went wrong. Please try again later.",
"cookies": "Cookies",
"cookiesDesc": "Chatenium is using cookies to securely store your session token. No cookies are used for tracking purposes",
"understood": "Understood",
"privacyPolicy": "Privacy policy",
"tos": "Terms of services",
"requestAlphaAccess": "Start beta testing",
"joinGoogleGroup": "Our Google Group",
"joinGoogleGroupWhy": "All you need to do is join our Google Group and you can join the beta testing program via the link provided in the group.",
"testFlight": "Apple TestFlight",
"testFlightDesc": "Start testing the application on your Apple device via TestFlight!",
"downloadOnWindows": "Get Chatenium On Windows",
"microsoftStore": "Microsoft Store",
"microsoftStoreDesc": "Start testing the application on your Windows computer via the Microsoft Store!"
},
"signIn": {
"formTitle": "Sign in",
"mainInput": {
@@ -18,7 +96,23 @@
}
},
"chat": {
"tabBar": {
"tab1": "Chats",
"tab2": "Networks",
"tab3": "Pictures",
"tab4": "Settings"
},
"changeLogDialog": {
"label": "Chatenium has been updated",
"changeLog": {
"1": "UI issue fixes in the message box",
"2": "Added caching to improve performance"
}
},
"chatnav": {
"pictureList": {
"myPictures": "My pictures"
},
"dmList": {
"newChat": "Start new chat",
"messageBox": {
@@ -26,10 +120,20 @@
"you": "You: "
}
}
},
"networkList": {
"newNetwork": "Start new network"
}
},
"pictures": {
"albumList": {
"noAlbum": "This user has no albums published"
}
},
"elements": {
"messageBox": {
"editMessageLabel": "Editing message: ",
"attachments": "Attachment(s)",
"placeholder": "Type a message...",
"message": "Message",
"uplDrag": {
@@ -39,6 +143,12 @@
"fileUploadDialog": {
"label": "Upload files"
}
},
"messages": {
"contextMenu": {
"edit": "Edit",
"delete": "Delete"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
public/icons/icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/icons/icon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
public/images/logo/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
public/images/logo/logo_beta.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/images/logo/logo_short.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/images/logo/logoold.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,57 @@
{
"name": "Chatenium",
"short_name": "Chtn",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

View File

@@ -1,15 +1,18 @@
import {provideTaiga} from '@taiga-ui/core';
import {ApplicationConfig, provideBrowserGlobalErrorListeners} from '@angular/core';
import {provideRouter} from '@angular/router';
import {ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode} from '@angular/core';
import {provideRouter, withRouterConfig} from '@angular/router';
import {routes} from './app.routes';
import {provideTranslateService} from '@ngx-translate/core';
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
import {provideServiceWorker} from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideRouter(routes, withRouterConfig({
paramsInheritanceStrategy: "always"
})),
provideTaiga(),
provideTranslateService({
fallbackLang: "en",
@@ -18,6 +21,10 @@ export const appConfig: ApplicationConfig = {
prefix: "/i18n/",
suffix: ".json"
})
}),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
})
],
};

View File

@@ -2,12 +2,29 @@ import {Routes} from '@angular/router';
import {SignIn} from './signin/signin';
import {Chat} from './chat/chat';
import {Dm} from './chat/dm/dm';
import {noAuthGuard} from './guards/no-auth-guard';
import {authNeededGuard} from './guards/auth-needed-guard';
import {Homepage} from './homepage/homepage';
import {Privacy} from './privacy/privacy';
import {TOS} from './tos/tos';
import {Network} from './chat/network/network';
import {Text} from './chat/network/channel/text/text';
import {See} from './chat/picture/see/see';
export const routes: Routes = [
{path: 'signin', component: SignIn},
{path: '', component: Homepage},
{path: 'privacy', component: Privacy},
{path: 'tos', component: TOS},
{path: 'signin', component: SignIn, canActivate: [noAuthGuard]},
{
path: 'chat', component: Chat, children: [
path: 'chat', component: Chat, canActivate: [authNeededGuard], children: [
{path: 'dm/:chatid', component: Dm},
{path: 'picture/:uploaderId', component: See},
{
path: 'network/:networkId', component: Network, children: [
{path: ":categoryId/:channelId", component: Text}
]
},
]
},
];

View File

@@ -1,12 +1,26 @@
<ng-template [(tuiDialog)]="changeLogOpen"
[tuiDialogOptions]="{closable: false, dismissible: false, label: 'chat.changeLogDialog.label'|translate}">
<h3>{{ "version"|translate }}</h3>
<ul>
<li>{{ "chat.changeLogDialog.changeLog.1"|translate }}</li>
<li>{{ "chat.changeLogDialog.changeLog.2"|translate }}</li>
</ul>
<button tuiButton iconStart="@tui.check"
(click)="localStorage.setItem('changeLogLastRead', environment.version); changeLogOpen.set(false)">{{ "ok"|translate }}
</button>
</ng-template>
@if (serviceManager.currentSession() == null) {
<main style="width: 100%; height: 100svh; display: flex; justify-content: center; align-items: center;">
<tui-loader size="xl"/>
</main>
} @else {
@if (breakpoint() != "mobile") {
<main id="layout" tuiGroup [collapsed]="true">
<aside id="chatnav">
<aside>
<tui-segmented id="mode_switcher">
<tui-segmented id="mode_switcher" [(activeItemIndex)]="navigationActiveIndex">
<button>
<tui-icon icon="@tui.message-circle"/>
</button>
@@ -18,22 +32,92 @@
</button>
</tui-segmented>
<button id="bottom_btn" tuiButton appearance="flat">
<button id="bottom_btn" tuiButton appearance="flat" disabled>
<tui-icon icon="@tui.cog"/>
</button>
</aside>
<main>
<app-dm-list [token]="serviceManager.currentSession()!.token" [userid]="serviceManager.currentSession()!.userData.userid"></app-dm-list>
@switch (navigationActiveIndex) {
@case (0) {
<app-dm-list [token]="serviceManager.currentSession()!.token"
[userid]="serviceManager.currentSession()!.userData.userid"></app-dm-list>
}
@case (1) {
<network-list [token]="serviceManager.currentSession()!.token"
[userid]="serviceManager.currentSession()!.userData.userid"></network-list>
}
@case (2) {
<app-picture-list [token]="serviceManager.currentSession()!.token"
[userid]="serviceManager.currentSession()!.userData.userid"></app-picture-list>
}
}
</main>
</aside>
<main id="content">
<div id="content_tint">
@if (router.url.startsWith("/chat/dm") || router.url.startsWith("/chat/picture")) {
<!-- To ensure data is loaded -->
@defer (when serviceManager.chatsStatus() != LoadStatus.loading) {
<router-outlet/>
}
} @else if (router.url.startsWith("/chat/network")) {
@defer (when serviceManager.networksStatus() != LoadStatus.loading) {
<router-outlet/>
}
} @else {
<router-outlet/>
}
</div>
</main>
</main>
} @else {
<main id="layout_mobile">
@if (!routerOutletActive()) {
<div id="navigation">
<div id="navigation_content">
@switch (navigationActiveIndex) {
@case (0) {
<app-dm-list [token]="serviceManager.currentSession()!.token"
[userid]="serviceManager.currentSession()!.userData.userid"></app-dm-list>
}
@case (1) {
<network-list [token]="serviceManager.currentSession()!.token"
[userid]="serviceManager.currentSession()!.userData.userid"></network-list>
}
}
</div>
<nav tuiTabBar [(activeItemIndex)]="navigationActiveIndex">
@for (item of tabBarItems; track item; let i = $index) {
<button
[disabled]="!item.implemented"
[class.disabled]="!item.implemented"
tuiTabBarItem
type="button"
[icon]="item.icon"
(click)="navigationActiveIndex = i"
>
{{ item.text | translate }}
</button>
}
</nav>
</div>
}
@if (router.url.startsWith("/chat/dm")) {
<!-- To ensure data is loaded -->
@defer (when serviceManager.chatsStatus() != LoadStatus.loading) {
<router-outlet (activate)="routerOutletActive.set(true)" (deactivate)="routerOutletActive.set(false)"/>
}
} @else if (router.url.startsWith("/chat/network")) {
@defer (when serviceManager.networksStatus() != LoadStatus.loading) {
<router-outlet (activate)="routerOutletActive.set(true)" (deactivate)="routerOutletActive.set(false)"/>
}
} @else {
<router-outlet (activate)="routerOutletActive.set(true)" (deactivate)="routerOutletActive.set(false)"/>
}
</main>
}
}

View File

@@ -7,6 +7,7 @@
#chatnav {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
height: 100svh;
aside {
padding: 15px;
@@ -25,6 +26,12 @@
button {
height: 50px;
tui-icon {
&::before {
color: var(--tui-background-accent-1);
}
}
}
}
@@ -41,18 +48,39 @@
border-radius: 20px 0 0 20px;
margin: 10px 0 10px 10px;
padding: 15px;
overflow-y: scroll;
}
}
#content {
width: 100%;
height: 100svh;
padding: 10px 10px 10px 0;
#content_tint {
border-radius: 0 20px 20px 0;
height: 100%;
background: var(--tui-background-neutral-2);
padding: 15px;
}
}
}
#layout_mobile {
height: 93svh;
#navigation {
height: 100%;
grid-template-rows: 1fr 50px;
button {
&.disabled {
opacity: 0.5;
}
}
#navigation_content {
height: 100%;
padding: 20px;
}
}
}

View File

@@ -1,13 +1,18 @@
import {Component, inject, OnInit} from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {Component, computed, inject, OnInit, signal} from '@angular/core';
import {isActive, IsActiveMatchOptions, Router, RouterOutlet} from '@angular/router';
import {TuiSegmented} from '@taiga-ui/kit';
import {TuiAppearance, TuiButton, TuiGroup, TuiIcon, TuiLoader} from '@taiga-ui/core';
import {TUI_BREAKPOINT, TuiAppearance, TuiButton, TuiDialog, TuiGroup, TuiIcon, TuiLoader} from '@taiga-ui/core';
import {SessionManager} from '@chatenium/chatenium-sdk/services/sessionManager';
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';
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {environment} from '../../environments/environment';
import {TuiTabBarComponent, TuiTabBarItem} from '@taiga-ui/addon-mobile';
import {NetworkList} from './network-list/network-list';
import {PictureList} from './picture-list/picture-list';
@Component({
selector: 'app-chat',
@@ -20,7 +25,13 @@ import {WebSocketHandler} from '@chatenium/chatenium-sdk/core/webSocketHandler';
DmList,
JsonPipe,
TuiAppearance,
TuiGroup
TuiGroup,
TuiDialog,
TranslatePipe,
TuiTabBarComponent,
TuiTabBarItem,
NetworkList,
PictureList
],
templateUrl: './chat.html',
styleUrl: './chat.scss',
@@ -28,14 +39,60 @@ import {WebSocketHandler} from '@chatenium/chatenium-sdk/core/webSocketHandler';
export class Chat implements OnInit {
serviceManager = inject(ServiceManager)
indexedDb = inject(IndexedDB)
breakpoint = inject(TUI_BREAKPOINT)
router = inject(Router)
routerOutletActive = signal(false)
navigationActiveIndex = 0
// Mobile navigation //
protected readonly tabBarItems = [
{
text: "chat.tabBar.tab1",
icon: '@tui.message-circle',
implemented: true,
},
{
text: "chat.tabBar.tab2",
icon: '@tui.network',
implemented: true,
},
{
text: "chat.tabBar.tab3",
icon: '@tui.image',
implemented: false,
},
{
text: "chat.tabBar.tab4",
icon: '@tui.cog',
implemented: false,
}
];
changeLogOpen = signal(false)
async ngOnInit() {
if (this.router.url.startsWith("/chat/network")) {
this.navigationActiveIndex = 1
} else if (this.router.url.startsWith("/chat/picture")) {
this.navigationActiveIndex = 2
}
this.indexedDb.openDatabase().then(async () => {
const session = await this.serviceManager.sessionManager.loadPreferredSession()
this.serviceManager.currentSession.set(session)
await WebSocketHandler.getInstance().connect(session.userData.userid, session.token)
})
setTimeout(() => {
const latestRead = localStorage.getItem("changeLogLastRead")
if (latestRead != environment.version) {
this.changeLogOpen.set(true)
}
}, 50)
}
protected readonly LoadStatus = LoadStatus;
protected readonly localStorage = localStorage;
protected readonly environment = environment;
}

View File

@@ -1,9 +1,14 @@
<button tuiButton appearance="secondary" iconStart="@tui.mail-plus">
<button disabled tuiButton appearance="secondary" iconStart="@tui.mail-plus">
{{"chat.chatnav.dmList.newChat"|translate}}
</button>
@if (serviceManager.chatsStatus() == LoadStatus.updating) {
<div id="loader">
<tui-loader />
{{"updating"|translate}}
</div>
}
@for (chat of serviceManager.chats(); track chat.chatid) {
<button tuiButton [appearance]="router.url == '/chat/dm/' + chat.chatid ? 'primary' : 'flat'" [routerLink]="'/chat/dm/' + chat.chatid">
<button [class.enlarge]="breakpoint() == 'mobile'" tuiButton [appearance]="router.url == '/chat/dm/' + chat.chatid ? 'primary' : 'flat'" [routerLink]="'/chat/dm/' + chat.chatid">
<oimg [src]="chat.pfp" height="35px" width="35px" [radius]="10"></oimg>
<div class="info">
@if (chat.displayName == "") {

View File

@@ -3,12 +3,24 @@
flex-direction: column;
gap: 5px;
#loader {
width: 100%;
display: flex;
justify-content: start;
padding: 5px;
gap: 5px;
}
button {
width: 100%;
display: flex;
justify-content: start;
font-weight: 600;
&.enlarge {
height: 75px;
}
.info {
display: flex;
flex-direction: column;

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DmList } from './dm-list';
describe('DmList', () => {
describe('PictureList', () => {
let component: DmList;
let fixture: ComponentFixture<DmList>;

View File

@@ -2,7 +2,7 @@ import {Component, inject, input, OnInit, signal} from '@angular/core';
import {ChatService} from '@chatenium/chatenium-sdk/services/chatService';
import {IndexedDB} from '../../storage/indexed-db';
import {Chat} from '@chatenium/chatenium-sdk/domain/chatService.schema';
import {TuiButton} from '@taiga-ui/core';
import {TUI_BREAKPOINT, TuiButton, TuiLoader} from '@taiga-ui/core';
import {Oimg} from '../elements/oimg/oimg';
import {Router, RouterLink} from '@angular/router';
import {TranslatePipe} from '@ngx-translate/core';
@@ -14,7 +14,8 @@ import {LoadStatus, ServiceManager} from '../../service-manager';
TuiButton,
Oimg,
RouterLink,
TranslatePipe
TranslatePipe,
TuiLoader
],
templateUrl: './dm-list.html',
styleUrl: './dm-list.scss',
@@ -26,9 +27,16 @@ export class DmList implements OnInit {
indexedDb = inject(IndexedDB)
router = inject(Router)
serviceManager = inject(ServiceManager)
breakpoint = inject(TUI_BREAKPOINT)
async ngOnInit() {
this.serviceManager.chatService = new ChatService(this.userid(), this.token(), this.indexedDb.getApi(), () => {})
try {
this.serviceManager.chats.set(await this.serviceManager.chatService.getQuick())
this.serviceManager.chatsStatus.set(LoadStatus.updating)
} catch (e) {
console.warn(`Cache load failed: ${e}. Skipping cache load...`)
}
try {
this.serviceManager.chats.set(await this.serviceManager.chatService.get())
this.serviceManager.chatsStatus.set(LoadStatus.loaded)
@@ -37,4 +45,6 @@ export class DmList implements OnInit {
this.serviceManager.chatsStatus.set(LoadStatus.error)
}
}
protected readonly LoadStatus = LoadStatus;
}

View File

@@ -1,6 +1,7 @@
<main [class.mobile]="breakpoint() == 'mobile'">
@defer (when store) {
<navbar>
<div class="items-left">
<navbar backButtonDest="/chat">
<div class="data">
<oimg [src]="store.chatData().pfp" height="50px" width="50px" [radius]="15"></oimg>
<div class="chat-data">
@if (store.chatData().displayName == "") {
@@ -13,13 +14,15 @@
</div>
<div class="items-right">
<button tuiButton appearance="flat">
<button tuiButton appearance="flat" disabled>
<tui-icon icon="@tui.phone"/>
</button>
</div>
</navbar>
<messages [messages]="store.messages()"/>
<messages [messageBoxViewModel]="store.messageBox" [messages]="store.messages()" id="scrollContainer" (onDelete)="deleteMessage($event)"/>
<message-box [viewModel]="store.messageBox"/>
}
</main>

View File

@@ -1,7 +1,17 @@
:host {
height: 95svh;
main {
height: 98svh;
display: grid;
grid-template-rows: 70px minmax(0, 1fr) auto;
padding: 15px;
&.mobile {
height: 100svh;
padding: 15px;
}
.mobile {
height: 5px;
}
navbar {
.chat-data {

View File

@@ -5,7 +5,7 @@ import {DMService} from '@chatenium/chatenium-sdk/services/dmService';
import {IndexedDB} from '../../storage/indexed-db';
import {Navbar} from '../elements/navbar/navbar';
import {Oimg} from '../elements/oimg/oimg';
import {TuiButton, TuiIcon} from '@taiga-ui/core';
import {TUI_BREAKPOINT, TuiButton, TuiIcon} from '@taiga-ui/core';
import {FileDataWithPreview, MessageBox} from '../elements/message-box/message-box';
import {Messages} from '../elements/messages/messages';
import {Chat} from '@chatenium/chatenium-sdk/domain/chatService.schema';
@@ -32,6 +32,7 @@ export class Dm implements OnInit {
serviceManager = inject(ServiceManager)
route = inject(ActivatedRoute)
indexedDb = inject(IndexedDB)
breakpoint = inject(TUI_BREAKPOINT)
chatid = ""
@@ -40,8 +41,29 @@ export class Dm implements OnInit {
}
async sendMessage(message: string, files: FileDataWithPreview[] | null) {
if (!files && message.trim() == "") return
const session = this.serviceManager.currentSession();
if (session != null) {
const editedMessage = this.store.messageBox.editingMessage()
if (editedMessage) {
const storedMsg = this.store.messages().find(m => m.msgid == editedMessage.messageId)
const originalMessage: Message = JSON.parse(JSON.stringify(storedMsg))
if (storedMsg) {
storedMsg.message = message
}
try {
await this.store.service.editMessage(editedMessage.messageId, message)
} catch (e) {
if (storedMsg) {
storedMsg.message = originalMessage.message
}
}
return
}
let attachments: Attachment[] = []
files?.forEach(file => {
const extraMetaData: Record<string, string> = {}
@@ -74,12 +96,40 @@ export class Dm implements OnInit {
forwardedFromName: ""
}])
this.scrollToBottom("smooth")
await this.store.service.sendMessage("", message, null, null, files, <FileUploadProgressListener>{
fileProgressUpdate: (tempMsgId, fileId, allChunks, chunksDone) => {
this.uploadProgressUpdate(tempMsgId, fileId, allChunks, chunksDone)
}
})
}
this.store.messageBox.message.set("")
}
async deleteMessage(messageId: string) {
const i = this.store.messages().findIndex(m => m.msgid == messageId)
if (i != -1) {
const originalMessage: Message = JSON.parse(JSON.stringify(this.store.messages()[i]))
this.store.messages().splice(i, 1)
try {
await this.store.service.deleteMessages([messageId])
} catch (e) {
this.store.messages().splice(i, 0, originalMessage)
}
}
}
scrollToBottom(anim: 'instant' | 'smooth'): void {
setTimeout(() => {
const scrollContainer = <HTMLDivElement>document.querySelector("#scrollContainer")
scrollContainer.scroll({
top: scrollContainer.scrollHeight,
left: 0,
behavior: anim
});
}, 0)
}
uploadProgressUpdate(tempMsgId: string, fileId: string, allChunks: number, chunksDone: number) {
@@ -95,6 +145,7 @@ export class Dm implements OnInit {
switch (action) {
case "newMessage":
targetStore.messages.update(messages => [...messages, data]);
this.scrollToBottom("smooth")
break;
}
}
@@ -105,13 +156,13 @@ export class Dm implements OnInit {
this.route.params.subscribe(async params => {
const chatid = params['chatid'];
this.chatid = chatid;
console.log(`Loading chat ${chatid}...`)
const session = this.serviceManager.currentSession();
const chatData = this.serviceManager.chats().find(c => c.chatid === chatid);
if (!session || !chatData) {
console.warn(`Initialization deferred for ${chatid}: Session or ChatData missing.`);
return;
return
}
if (!this.serviceManager.dmServices()[chatid]) {
@@ -133,8 +184,15 @@ export class Dm implements OnInit {
this.serviceManager.dmServices()[chatid] = newStore;
const currentStore = this.serviceManager.dmServices()[chatid];
const history = await currentStore.service.get();
currentStore.messages.set(history);
try {
const messagesCache = await currentStore.service.getQuick();
currentStore.messages.set(messagesCache);
} catch (e) {
console.warn(`Cache load failed: ${e}. Skipping cache load...`)
}
const messages = await currentStore.service.get();
currentStore.messages.set(messages);
this.scrollToBottom("instant")
await currentStore.service.joinWebSocketRoom();
}

View File

@@ -1,9 +1,8 @@
:host {
display: grid;
gap: 4px;
height: 100%;
img {
::ng-deep img {
display: block;
height: 100%;
width: 100%;

View File

@@ -12,4 +12,5 @@ export class MessageBoxViewModel {
message = signal<string>("")
files = signal<FileDataWithPreview[]>([])
dialogOpen = signal<boolean>(false)
editingMessage = signal<{messageId: string, message: string} | null >(null)
}

View File

@@ -5,7 +5,7 @@
</div>
<span>{{ "chat.elements.messageBox.uplDrag.upload"|translate }}</span>
</div>
<div class="method">
<div class="method" style="opacity: 0.5">
<div class="icon-holder">
<tui-icon icon="@tui.cloud-sync"/>
</div>
@@ -14,13 +14,25 @@
</div>
<ng-template [(tuiDialog)]="viewModel().dialogOpen" [tuiDialogOptions]="{label: 'chat.elements.messageBox.fileUploadDialog.label'|translate}">
<masonry [maxColSize]="2" style="overflow-y: scroll; height: 500px">
@if (filterPictureVideo(viewModel().files()).length != 0) {
<masonry [maxColSize]="2" style="overflow-y: scroll; height: 200px">
@for (file of viewModel().files(); track file) {
@if (file.type == "image") {
<img [src]="file.blob" style="width: 100%; height: 100%; object-fit: fill;"/>
}
}
</masonry>
}
<div tuiGroup orientation="vertical" style="width: 100%">
@for (file of viewModel().files(); track file) {
@if (file.type != "image") {
<div style="background: var(--tui-background-neutral-1); height: 50px; display: flex; align-items: center; padding: 0 10px">
<span>{{ file.name }}</span>
</div>
}
}
</div>
<div style="margin-top: 10px; display: flex; gap: 10px; width: 100%">
<tui-textfield style="width: 100%">
@@ -37,25 +49,45 @@
</div>
</ng-template>
<div id="message-box" [style]="'border-radius:'+messageBoxRadius+'px;'">
<main>
<div id="message-box-extension" tuiAppearance="floating" [class.shown]="viewModel().editingMessage()" [class.fullWidth]="breakpoint() != 'desktopLarge'">
<div id="content">
@if (viewModel().editingMessage()) {
<tui-icon icon="@tui.pencil"></tui-icon>
<span>{{"chat.elements.messageBox.editMessageLabel"|translate}}</span>
@if (viewModel().editingMessage()!.message == "") {
<tui-icon icon="@tui.paperclip"></tui-icon>
<span>{{"chat.elements.messageBox.attachments"|translate}}</span>
} @else {
<span style="width: 300px" tuiScrollable tuiFade>{{viewModel().editingMessage()!.message}}</span>
}
}
</div>
<button id="close" tuiButton appearance="flat" (click)="viewModel().editingMessage.set(null); viewModel().message.set('');">
<tui-icon icon="@tui.x"/>
</button>
</div>
<div id="message-box" [style]="'border-radius:'+messageBoxRadius+'px;'" [class.fullWidth]="breakpoint() != 'desktopLarge'">
<div class="items-left">
<button tuiButton appearance="flat" (click)="uplInput.click()">
<tui-icon icon="@tui.file-up"/>
<input #uplInput type="file" (change)="handleFileInput($event)" multiple hidden/>
</button>
<button tuiButton appearance="flat">
<button tuiButton appearance="flat" disabled>
<tui-icon icon="@tui.cloud-sync"/>
</button>
</div>
<div class="items-middle">
<textarea [style]="'height:'+textareaHeight+'px;'" #message (input)="onTextAreaInput(message)" [(ngModel)]="viewModel().message"></textarea>
<textarea (keydown.enter)="viewModel().message.set(''); handleEnterKeydown($event); viewModel().onMessageSend(message.value, null)" [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, null)">
<button [disabled]="message.value.trim() == ''" tuiButton appearance="flat" (click)="viewModel().message.set(''); viewModel().onMessageSend(message.value, null)">
<tui-icon icon="@tui.send"/>
</button>
</div>
</div>
</main>

View File

@@ -48,7 +48,53 @@
}
}
main {
width: 100%;
display: flex;
align-items: center;
flex-direction: column;
gap: 5px;
#message-box-extension {
transition: all 0.2s ease-in-out;
width: 50%;
border-radius: 20px;
background: var(--tui-background-base-alt);
border: 2px solid var(--tui-border-normal);
transform: translateY(50px);
display: grid;
grid-template-columns: 1fr 40px;
justify-content: center;
height: 0;
&.fullWidth {
width: 100%;
}
#content {
padding-left: 10px;
display: flex;
gap: 5px;
align-items: center;
}
tui-icon {
font-size: 13px;
}
button {
height: 26px;
width: 30px;
}
&.shown {
transform: translateY(0);
height: 30px;
}
}
#message-box {
z-index: 5;
transition: all 0.2s ease-in-out;
width: 60%;
background: var(--tui-background-base-alt);
@@ -60,6 +106,10 @@
align-items: center;
padding: 0 10px;
&.fullWidth {
width: 100%;
}
.items-left, .items-middle, .items-right {
display: flex;
align-items: center;
@@ -117,3 +167,4 @@
}
}
}
}

View File

@@ -1,10 +1,11 @@
import {ChangeDetectorRef, Component, HostListener, inject, input} from '@angular/core';
import {
TUI_BREAKPOINT,
TuiAppearance,
TuiButton,
TuiDialog,
TuiGroup,
TuiIcon,
TuiIcon, TuiScrollable,
TuiScrollbarDirective,
TuiTextfield
} from '@taiga-ui/core';
@@ -16,7 +17,7 @@ import {v4 as uuidv4} from 'uuid';
import {Masonry} from '../masonry/masonry';
import {Oimg} from '../oimg/oimg';
import {FileData} from '@chatenium/chatenium-sdk/domain/fileUploadService.schema';
import {TuiTextarea, TuiTextareaComponent} from '@taiga-ui/kit';
import {TuiFade, TuiTextarea, TuiTextareaComponent} from '@taiga-ui/kit';
@Component({
selector: 'message-box',
@@ -32,7 +33,9 @@ import {TuiTextarea, TuiTextareaComponent} from '@taiga-ui/kit';
Oimg,
Masonry,
TuiTextfield,
TuiTextarea
TuiTextarea,
TuiScrollable,
TuiFade
],
templateUrl: './message-box.html',
styleUrl: './message-box.scss',
@@ -40,6 +43,7 @@ import {TuiTextarea, TuiTextareaComponent} from '@taiga-ui/kit';
export class MessageBox {
viewModel = input.required<MessageBoxViewModel>()
breakpoint = inject(TUI_BREAKPOINT)
cdr = inject(ChangeDetectorRef)
textareaHeight = 25
@@ -99,8 +103,19 @@ export class MessageBox {
sendMessageWithCaption() {
this.viewModel().onMessageSend(this.viewModel().message(), this.viewModel().files())
this.viewModel().message.set("")
this.viewModel().files.set([])
this.viewModel().dialogOpen.set(false)
this.viewModel().editingMessage.set(null)
}
handleEnterKeydown(e: any) {
e.preventDefault()
return false
}
filterPictureVideo(files: FileDataWithPreview[]) {
return files.filter(f => f.type == "image")
}
async processFiles(fileList: FileList) {

View File

@@ -2,7 +2,10 @@
@for (message of messages(); track message.msgid; let i = $index) {
<div
class="message"
[class.author]="message.author == serviceManager.currentSession()!.userData.userid"
[class.author]="
messageAsDm(message).author == serviceManager.currentSession()!.userData.userid ||
messageAsNetwork(message).author.userid == serviceManager.currentSession()!.userData.userid
"
[class.chained_start]="isMessageStartOfChain(i)"
[class.chained_middle]="isMessageMiddleInChain(i)"
[class.chained_end]="isMessageEndOfChain(i)"
@@ -10,23 +13,57 @@
<div class="above">
<span>{{ message.sent_at.T * 1000 | date: 'HH:mm' }}</span>
</div>
<div class="bubble">
<div class="bubble" tuiDropdownContext [tuiDropdown]="messageContextMenu">
<span class="message-text">{{ message.message }}</span>
<masonry style="max-height: 300px">
@for (file of message.files; track file) {
@for (file of filterExpressedMedia(message.files); track file) {
@if (file.type == "image") {
<img [src]="file.path" style="width: 100%; height: 100%; max-height: 300px; object-fit: cover; border-radius: 25px"/>
<img [src]="file.path"
style="width: 100%; height: 100%; max-height: 300px; object-fit: cover; border-radius: 25px"/>
} @else if (file.type == "video") {
@if (file.extraMetaData && Object.keys(file.extraMetaData).length > 0) {
<video-player maxHeight="250px" maxWidth="250px" [src]="file.path" [thumbnailOverwrite]="file.extraMetaData['thumbnailMetaData']"></video-player>
<video-player maxHeight="300px" maxWidth="300px" [src]="file.path"
[thumbnailOverwrite]="file.extraMetaData['thumbnailMetaData']"></video-player>
} @else {
<video-player maxHeight="250px" maxWidth="250px" [src]="file.path"></video-player>
<video-player maxHeight="300px" maxWidth="300px" [src]="file.path"></video-player>
}
}
}
</masonry>
<div tuiGroup orientation="vertical" style="width: 100%">
@for (file of filterNonExpressedMedia(message.files); track file) {
<div style="width: 100%; height: 50px; display: flex; align-items: center; padding: 0 10px; background: var(--tui-background-accent-1); gap: 5px">
<tui-icon icon="@tui.file"/>
<span>{{ file.fileName }}</span>
</div>
}
</div>
</div>
<div class="below"></div>
<ng-template #messageContextMenu>
<tui-data-list>
<button
tuiOption
type="button"
iconEnd="@tui.pencil"
(click)="messageBoxViewModel().editingMessage.set({message: message.message, messageId: message.msgid}); messageBoxViewModel().message.set(message.message);"
>
{{ "chat.elements.messages.contextMenu.edit" | translate }}
</button>
<button
style="color: var(--tui-text-negative)"
tuiOption
type="button"
iconEnd="@tui.trash"
(click)="onDelete.emit(message.msgid)"
>
{{ "chat.elements.messages.contextMenu.delete" | translate }}
</button>
</tui-data-list>
</ng-template>
</div>
}
}

View File

@@ -4,10 +4,12 @@
flex-direction: column;
gap: 2px;
overflow-y: scroll;
overflow-x: hidden;
.message {
display: flex;
flex-direction: column;
align-items: start;
&.author {
align-items: end;
@@ -56,6 +58,7 @@
max-width: 50%;
min-width: 250px;
min-height: 40px;
max-height: 400px;
display: flex;
flex-direction: column;
justify-content: center;

View File

@@ -1,17 +1,38 @@
import {Component, inject, input} from '@angular/core';
import {Component, EventEmitter, inject, input, Output} 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';
import {Masonry} from '../masonry/masonry';
import {VideoPlayer} from '../video-player/video-player';
import {
TuiButton,
TuiDataListComponent,
TuiDropdown,
TuiDropdownContext,
TuiGroup,
TuiIcon,
TuiOption
} from '@taiga-ui/core';
import {TranslatePipe} from '@ngx-translate/core';
import {MessageBoxViewModel} from '../message-box/message-box-viewmodel';
import {FileDataWithPreview} from '../message-box/message-box';
import {Attachment} from '@chatenium/chatenium-sdk/domain/common.schema';
@Component({
selector: 'messages',
imports: [
DatePipe,
Masonry,
VideoPlayer
VideoPlayer,
TuiDropdownContext,
TuiDropdown,
TuiButton,
TuiOption,
TranslatePipe,
TuiDataListComponent,
TuiGroup,
TuiIcon
],
templateUrl: './messages.html',
styleUrl: './messages.scss',
@@ -19,7 +40,10 @@ import {VideoPlayer} from '../video-player/video-player';
export class Messages {
serviceManager = inject(ServiceManager)
messages = input<Message[] | NetworkMessage[]>([])
messages = input.required<Message[] | NetworkMessage[]>()
messageBoxViewModel = input.required<MessageBoxViewModel>()
@Output() onDelete = new EventEmitter<string>()
/**
* Helps code readability by specifying what type of messages are being processed.
@@ -111,5 +135,13 @@ export class Messages {
return i + 1 === this.messages().length;
}
filterExpressedMedia(files: Attachment[]) {
return files.filter(f => f.type == "image" || f.type == "video")
}
filterNonExpressedMedia(files: Attachment[]) {
return files.filter(f => f.type != "image" && f.type != "video")
}
protected readonly Object = Object;
}

View File

@@ -1,3 +1,13 @@
<nav>
<nav [class.includeBackButton]="breakpoint() == showBackButtonAt()" [class.mobile]="breakpoint() == 'mobile'" [class.alignCenter]="dataAlignment() == 'center'">
@if (breakpoint() == showBackButtonAt()) {
<div>
<button (click)="router.navigate([backButtonDest()])" tuiButton appearance="flat" iconStart="@tui.chevron-left">
{{"back"|translate}}
</button>
</div>
}
@if (dataAlignment() == "center" && breakpoint() != showBackButtonAt()) {
<div></div> <!-- filler to align data to center -->
}
<ng-content></ng-content>
</nav>

View File

@@ -2,7 +2,31 @@ nav {
display: grid;
grid-template-columns: 1fr 1fr;
::ng-deep .items-right, ::ng-deep .items-left {
&.includeBackButton {
grid-template-columns: 1fr 1fr 1fr;
::ng-deep .data {
display: flex;
justify-content: center;
align-items: center;
}
}
&.mobile {
zoom: 0.85;
}
&.alignCenter {
grid-template-columns: 1fr 1fr 1fr;
::ng-deep .data {
display: flex;
justify-content: center;
align-items: center;
}
}
::ng-deep .items-right, ::ng-deep .data {
display: flex;
gap: 10px;
align-items: center;

View File

@@ -1,9 +1,27 @@
import { Component } from '@angular/core';
import {Component, inject, input} from '@angular/core';
import {TUI_BREAKPOINT, TuiButton} from '@taiga-ui/core';
import {TuiAppBarComponent} from '@taiga-ui/layout';
import {TuiPlatform} from '@taiga-ui/cdk';
import {NgTemplateOutlet} from '@angular/common';
import {TranslatePipe} from '@ngx-translate/core';
import {Router} from '@angular/router';
@Component({
selector: 'navbar',
imports: [],
imports: [
TuiAppBarComponent,
TuiPlatform,
NgTemplateOutlet,
TuiButton,
TranslatePipe
],
templateUrl: './navbar.html',
styleUrl: './navbar.scss',
})
export class Navbar {}
export class Navbar {
breakpoint = inject(TUI_BREAKPOINT)
router = inject(Router)
backButtonDest = input.required<string>()
showBackButtonAt = input<"desktopSmall"|"mobile">("mobile")
dataAlignment = input<"left"|"center">("left")
}

View File

@@ -1 +1 @@
<img [src]="src()" [style]="'width:'+width()+';height:'+height()+'; border-radius: '+radius()+'px;'" class="">
<img [src]="src()" [style]="'width:'+width()+';height:'+height()+'; border-radius: '+radius()+'px; object-fit:'+objectFit()" class="">

View File

@@ -0,0 +1,3 @@
img {
display: block;
}

View File

@@ -15,4 +15,5 @@ export class Oimg {
width = input("")
src = input("")
radius = input(15)
objectFit = input("cover")
}

View File

@@ -1,7 +1,7 @@
@if (playerActive) {
<div id="player" [style]="'max-width:'+maxWidth+';max-height:'+maxHeight" (mouseover)="controlShowed = true"
(mouseleave)="controlShowed = false" #player>
<video (mouseout)="showVolRange = false" [class.upScale]="videoFullscreen"
<video (mouseout)="showVolRange = false" (mouseover)="showVolRange = false" [class.upScale]="videoFullscreen"
[style]="'max-width:'+maxWidth+';height:'+maxHeight+';border-radius: 15px'" (pause)="videoPlaying = false"
(play)="videoPlaying = true" #video (timeupdate)="watched = videoHMSFormat(video.currentTime)"
(loadedmetadata)="videoPlayer = video; video.style.display = 'block'; videoLoaded = true"
@@ -15,28 +15,21 @@
<div id="controlsHolder">
<div id="controls">
<div id="buttons">
<button tuiButton appearance="icon"
(click)="videoPlaying ? video.pause() : video.play();">
(mouseover)="showVolRange = false"
(click)="videoPlaying ? video.pause() : video.play();" id="left">
@if (videoPlaying) {
<tui-icon icon="@tui.pause"></tui-icon>
} @else {
<tui-icon icon="@tui.play"></tui-icon>
}
</button>
<span>{{ watched }}</span>
<progress class="timeProgress" (click)="jump($event)" [max]="video.duration" [value]="video.currentTime"
tuiProgressBar size="xs"></progress>
<div>
<!-- @if (showVolRange) { -->
<!-- } -->
<div class="volumeSetter" (mouseover)="showVolRange = true">
<div id="right">
<button
type="button"
tuiButton appearance="icon"
tuiDropdown
tuiDropdownAppearance="neutral"
tuiDropdownAuto
tuiDropdownDirection="top"
tuiDropdownLimitWidth="fixed"
tuiIconButton
@@ -50,19 +43,16 @@
} @else {
<tui-icon icon="@tui.volume"></tui-icon>
}
</button>
<input
*tuiDropdown
tuiSlider
type="range"
value="100"
(input)="setVolume(volume)"
[(ngModel)]="volume"
/>
</button>
</div>
</div>
<button tuiButton appearance="icon"
(mouseover)="showVolRange = false"
(click)="videoFullscreen ? exitFullScreen() : player.requestFullscreen(); videoFullscreen = !videoFullscreen">
@if (videoFullscreen) {
<tui-icon icon="@tui.minimize"></tui-icon>
@@ -72,6 +62,13 @@
</button>
</div>
</div>
<div id="info">
<span>{{ watched }}</span>
<progress class="timeProgress" (click)="jump($event)" [max]="video.duration" [value]="video.currentTime"
tuiProgressBar size="xs"></progress>
</div>
</div>
</div>
</div>
} @else {
@@ -79,7 +76,8 @@
@if (thumbnailOverwrite) {
<img [style]="'max-width:'+maxWidth+';max-height:'+maxHeight+';border-radius: 15px'" [src]="thumbnailOverwrite"/>
} @else {
<oimg [height]="maxHeight" [width]="maxWidth" style="border-radius: 15px" [src]="src+'_thumbnail.png'"></oimg>
<oimg [height]="maxHeight" [width]="maxWidth" style="border-radius: 15px; object-fit: cover"
[src]="src+'_thumbnail.png'"></oimg>
}
<button tuiButton (click)="playVideo()">
<tui-icon icon="@tui.play"></tui-icon>

View File

@@ -36,15 +36,30 @@
#controls {
width: 95%;
height: 50px;
height: 60px;
background: var(--tui-background-base-alt);
border: 2px solid var(--tui-border-normal);
padding: 10px;
border-radius: 20px;
gap: 5px;
display: flex;
flex-direction: column;
#info {
display: grid;
grid-template-columns: 50px 50px 1fr 50px 50px;
grid-template-columns: 50px 1fr;
align-items: center;
}
#buttons {
display: grid;
grid-template-columns: 50px 1fr;
align-items: center;
#right {
display: flex;
align-items: center;
justify-content: end;
}
button {
height: 25px;
@@ -66,6 +81,7 @@
}
}
}
}
.player_preview {
max-width: 400px;
@@ -92,6 +108,7 @@ tui-dropdown[data-appearance] {
box-shadow: none;
border: none;
backdrop-filter: blur(1rem);
position: fixed;
[tuiSlider] {
position: absolute;

View File

@@ -1,7 +1,7 @@
import {Component, Input, ViewEncapsulation} from '@angular/core';
import {
TuiButton,
TuiDropdownDirective,
TuiDropdown,
TuiDropdownOpen,
TuiDropdownOptionsDirective,
TuiIcon,
@@ -21,7 +21,7 @@ import {TuiInputDateRange, TuiProgressBar} from '@taiga-ui/kit';
TuiSlider,
TuiProgressBar,
TuiDropdownOptionsDirective,
TuiDropdownDirective,
TuiDropdown,
TuiDropdownOpen,
TuiInputDateRange
],

View File

@@ -0,0 +1,19 @@
<button disabled tuiButton appearance="secondary" iconStart="@tui.plus">
{{ "chat.chatnav.networkList.newNetwork"|translate }}
</button>
@if (serviceManager.networksStatus() == LoadStatus.updating) {
<div id="loader">
<tui-loader />
{{"updating"|translate}}
</div>
}
@for (network of serviceManager.networks(); track network.networkId) {
<button [class.enlarge]="breakpoint() == 'mobile'" tuiButton
[appearance]="router.url.startsWith('/chat/network/' + network.networkId) ? 'primary' : 'flat'"
[routerLink]="'/chat/network/' + network.networkId">
<oimg [src]="network.picture" height="35px" width="35px" [radius]="10"></oimg>
<div class="info">
<span>{{ network.name }}</span>
</div>
</button>
}

View File

@@ -0,0 +1,28 @@
:host {
display: flex;
flex-direction: column;
gap: 5px;
button {
width: 100%;
display: flex;
justify-content: start;
font-weight: 600;
&.enlarge {
height: 75px;
}
.info {
display: flex;
flex-direction: column;
text-align: start;
.latest_message {
margin-top: -5px;
font-size: 12px;
opacity: 50%;
}
}
}
}

View File

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

View File

@@ -0,0 +1,50 @@
import {Component, inject, input, OnInit} from '@angular/core';
import {IndexedDB} from '../../storage/indexed-db';
import {Router, RouterLink} from '@angular/router';
import {LoadStatus, ServiceManager} from '../../service-manager';
import {TUI_BREAKPOINT, TuiButton, TuiLoader} from '@taiga-ui/core';
import {ChatService} from '@chatenium/chatenium-sdk/services/chatService';
import {NetworkService} from '@chatenium/chatenium-sdk/services/networkService';
import {Oimg} from '../elements/oimg/oimg';
import {TranslatePipe} from '@ngx-translate/core';
@Component({
selector: 'network-list',
imports: [
Oimg,
TranslatePipe,
TuiButton,
RouterLink,
TuiLoader
],
templateUrl: './network-list.html',
styleUrl: './network-list.scss',
})
export class NetworkList implements OnInit {
userid = input<string>("")
token = input<string>("")
indexedDb = inject(IndexedDB)
router = inject(Router)
serviceManager = inject(ServiceManager)
breakpoint = inject(TUI_BREAKPOINT)
async ngOnInit() {
this.serviceManager.networkService = new NetworkService(this.userid(), this.token(), "", this.indexedDb.getApi(), () => {})
try {
this.serviceManager.networks.set(await this.serviceManager.networkService.getQuick())
this.serviceManager.networksStatus.set(LoadStatus.updating)
} catch (e) {
console.warn(`Cache load failed: ${e}. Skipping cache load...`)
}
try {
this.serviceManager.networks.set(await this.serviceManager.networkService.get())
this.serviceManager.networksStatus.set(LoadStatus.loaded)
} catch (e) {
console.error(e)
this.serviceManager.networksStatus.set(LoadStatus.error)
}
}
protected readonly LoadStatus = LoadStatus;
}

View File

@@ -0,0 +1,34 @@
<main [class.mobile]="breakpoint() == 'mobile'">
@defer (when store) {
<navbar backButtonDest="/chat/network/{{networkId}}" showBackButtonAt="desktopSmall">
<div class="data">
<div class="chat-data">
@switch (store!.channelData().type) {
@case ("message") {
<tui-icon icon="@tui.hash"></tui-icon>
}
@case ("broadcast") {
<tui-icon icon="@tui.radio"></tui-icon>
}
@case ("voice") {
<tui-icon icon="@tui.audio-lines"></tui-icon>
}
}
<span class="main-name">{{ store!.channelData().name }}</span>
</div>
</div>
<div class="items-right">
<button tuiButton appearance="flat" disabled>
<tui-icon icon="@tui.phone"/>
</button>
</div>
</navbar>
<messages [messageBoxViewModel]="store!.messageBox" [messages]="store!.messages()" id="scrollContainer"
(onDelete)="deleteMessage($event)"/>
<message-box [viewModel]="store!.messageBox"/>
}
</main>

View File

@@ -0,0 +1,43 @@
main {
height: 98svh;
display: grid;
grid-template-rows: 70px minmax(0, 1fr) auto;
padding: 15px;
&.mobile {
height: 100svh;
padding: 15px;
}
.mobile {
height: 5px;
}
navbar {
.chat-data {
display: flex;
justify-content: center;
gap: 5px;
.main-name {
font-size: 18px;
font-weight: bold;
}
.alt-name {
margin-top: -5px;
color: gray;
font-size: 12px;
}
}
.items-right {
margin-top: -10px;
button {
width: 35px;
height: 35px;
}
}
}
}

View File

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

View File

@@ -0,0 +1,238 @@
import {Component, inject, signal} from '@angular/core';
import {DmStorage, ServiceManager, TextChannelStorage} from '../../../../service-manager';
import {ActivatedRoute} from '@angular/router';
import {IndexedDB} from '../../../../storage/indexed-db';
import {TUI_BREAKPOINT, TuiButton, TuiIcon} from '@taiga-ui/core';
import {FileDataWithPreview, MessageBox} from '../../../elements/message-box/message-box';
import {Attachment} from '@chatenium/chatenium-sdk/domain/common.schema';
import {FileUploadProgressListener} from '@chatenium/chatenium-sdk/domain/fileUploadService.schema';
import {MessageBoxViewModel} from '../../../elements/message-box/message-box-viewmodel';
import {TextChannelServiceService} from '@chatenium/chatenium-sdk/services/textChannelService';
import {Message} from '@chatenium/chatenium-sdk/domain/textChannelService.schema';
import {Messages} from '../../../elements/messages/messages';
import {Navbar} from '../../../elements/navbar/navbar';
import {Oimg} from '../../../elements/oimg/oimg';
@Component({
selector: 'app-text',
imports: [
MessageBox,
Messages,
Navbar,
Oimg,
TuiButton,
TuiIcon
],
templateUrl: './text.html',
styleUrl: './text.scss',
})
export class Text {
serviceManager = inject(ServiceManager)
route = inject(ActivatedRoute)
indexedDb = inject(IndexedDB)
breakpoint = inject(TUI_BREAKPOINT)
networkId = ""
channelId = ""
categoryId = ""
get store(): TextChannelStorage | null {
if (!this.serviceManager.networkServices()[this.networkId]) {
return null
}
return this.serviceManager.networkServices()[this.networkId].textChannels()[this.channelId]
}
async sendMessage(message: string, files: FileDataWithPreview[] | null) {
if (!this.store) return
if (!files && message.trim() == "") return
const session = this.serviceManager.currentSession();
if (session != null) {
const editedMessage = this.store.messageBox.editingMessage()
if (editedMessage) {
const storedMsg = this.store.messages().find(m => m.msgid == editedMessage.messageId)
const originalMessage: Message = JSON.parse(JSON.stringify(storedMsg))
if (storedMsg) {
storedMsg.message = message
}
try {
await this.store.service.editMessage(editedMessage.messageId, message)
} catch (e) {
if (storedMsg) {
storedMsg.message = originalMessage.message
}
}
this.store.messageBox.editingMessage.set(null)
this.store.messageBox.message.set("")
return
}
let attachments: Attachment[] = []
files?.forEach(file => {
const extraMetaData: Record<string, string> = {}
extraMetaData["thumbnailMetaData"] = file.videoThumbnail ?? ""
attachments.push({
fileName: file.name,
fileId: file.fileId,
type: file.type,
format: file.extension,
path: file.blob,
height: file.height,
width: file.width,
extraMetaData: extraMetaData
})
})
this.store.messages.update(value => [...value, {
author: {
userid: session.userData.userid,
pfp: session.userData.pfp,
username: session.userData.username,
displayName: session.userData.displayName
},
msgid: "",
message: message,
sent_at: {
T: 0,
I: 0
},
isEdited: false,
channelId: "",
networkId: "",
categoryId: "",
files: [],
seen: false,
replyTo: "",
replyToId: "",
forwardedFrom: "",
forwardedFromName: ""
}])
this.scrollToBottom("smooth")
await this.store.service.sendMessage("", message, null, null, files, <FileUploadProgressListener>{
fileProgressUpdate: (tempMsgId, fileId, allChunks, chunksDone) => {
this.uploadProgressUpdate(tempMsgId, fileId, allChunks, chunksDone)
}
})
}
}
async deleteMessage(messageId: string) {
if (!this.store) return
const i = this.store.messages().findIndex(m => m.msgid == messageId)
if (i != -1) {
const originalMessage: Message = JSON.parse(JSON.stringify(this.store.messages()[i]))
this.store.messages().splice(i, 1)
try {
await this.store.service.deleteMessages([messageId])
} catch (e) {
this.store.messages().splice(i, 0, originalMessage)
}
}
}
scrollToBottom(anim: 'instant' | 'smooth'): void {
setTimeout(() => {
const scrollContainer = <HTMLDivElement>document.querySelector("#scrollContainer")
scrollContainer.scroll({
top: scrollContainer.scrollHeight,
left: 0,
behavior: anim
});
}, 0)
}
uploadProgressUpdate(tempMsgId: string, fileId: string, allChunks: number, chunksDone: number) {
console.log(fileId, allChunks, chunksDone)
}
// The chatid parameter ensures isolation
onWsListen(action: string, message: string, networkId: string, channelId: string) {
const data = JSON.parse(message);
if (data.channelId === channelId) {
const targetStore = this.serviceManager.networkServices()[networkId].textChannels()[channelId];
if (targetStore) {
switch (action) {
case "newMessage":
targetStore.messages.update(messages => [...messages, data]);
this.scrollToBottom("smooth")
break;
}
}
}
}
ngOnInit() {
this.route.params.subscribe(async params => {
const networkId = params['networkId'];
const categoryId = params['categoryId'];
const channelId = params['channelId'];
this.networkId = networkId;
this.categoryId = categoryId;
this.channelId = channelId;
const session = this.serviceManager.currentSession();
const networkData = this.serviceManager.networks().find(c => c.networkId === networkId);
if (!session || !networkData) {
console.warn("No network data")
return
}
const categoryData = networkData.categories.find(c => c.categoryId === categoryId);
if (!categoryData) {
console.warn("No category data")
return
}
const channelData = categoryData.channels.find(c => c.channelId === channelId);
if (!channelData) {
console.warn("No channel data")
return
}
if (!this.serviceManager.networkServices()[networkId]) {
console.warn("No networkService")
return
}
console.log(this.serviceManager.networkServices())
console.log(this.serviceManager.networkServices()[networkId])
if (!this.serviceManager.networkServices()[networkId].textChannels()[channelId]) {
const newStore = {
categoryData: signal(categoryData),
channelData: signal(channelData),
messages: signal<Message[]>([]),
messageBox: new MessageBoxViewModel((msg, files) => this.sendMessage(msg, files)),
wsListener: (action, data) => this.onWsListen(action, data, networkId, categoryId),
} as TextChannelStorage;
newStore.service = new TextChannelServiceService(
session.userData.userid,
session.token,
networkId,
categoryId,
channelId,
this.indexedDb.getApi(),
(action, data) => newStore.wsListener(action, data)
);
this.serviceManager.networkServices()[networkId].textChannels()[channelId] = newStore;
const currentStore = this.serviceManager.networkServices()[networkId].textChannels()[channelId]
try {
const messagesCache = await currentStore.service.getQuick();
currentStore.messages.set(messagesCache);
} catch (e) {
console.warn(`Cache load failed: ${e}. Skipping cache load...`)
}
const messages = await currentStore.service.get();
currentStore.messages.set(messages);
await currentStore.service.joinWebSocketRoom();
}
});
}
}

View File

@@ -0,0 +1,44 @@
@defer(when store) {
<div id="layout" [class.routerOutletActive]="routerOutletActive()" [class.hideNetworkNav]="routerOutletActive() && breakpoint() == 'desktopSmall'">
<div id="router">
<router-outlet (activate)="routerOutletActive.set(true)" (deactivate)="routerOutletActive.set(false)"></router-outlet>
</div>
<div id="network-data">
<navbar backButtonDest="/chat" dataAlignment="center">
<div class="data">
<oimg [src]="store.networkData().picture" height="50px" width="50px" [radius]="15"></oimg>
<h2>{{store.networkData().name}}</h2>
</div>
<div class="items-right"></div>
</navbar>
<main tuiGroup orientation="vertical" style="width: 100%">
@for (category of store.networkData().categories; track category) {
<div class="category">
<h2>{{category.name}}</h2>
<div tuiGroup orientation="vertical" style="width: 100%">
@for (channel of category.channels; track channel) {
<button tuiButton class="channel" [appearance]="router.url.endsWith(channel.channelId) ? 'primary' : 'secondary'" [disabled]="channel.type != 'message'" [routerLink]="'/chat/network/'+store.networkData().networkId+'/'+category.categoryId+'/'+channel.channelId">
@switch (channel.type) {
@case ("message") {
<tui-icon icon="@tui.hash"></tui-icon>
}
@case ("broadcast") {
<tui-icon icon="@tui.radio"></tui-icon>
}
@case ("voice") {
<tui-icon icon="@tui.audio-lines"></tui-icon>
}
}
<span>{{channel.name}}</span>
</button>
}
</div>
</div>
}
</main>
</div>
</div>
}

View File

@@ -0,0 +1,45 @@
#layout {
overflow-y: scroll;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 0 100%;
overflow-x: hidden;
padding: 15px;
transition: 0.2s;
&.routerOutletActive {
overflow-y: hidden;
padding: 0;
grid-template-columns: 1fr 300px;
#router {
background: var(--tui-background-neutral-1);
}
main {
padding: 15px;
}
}
&.hideNetworkNav {
grid-template-columns: 1fr;
#network-data {
&.hidden {
display: none;
}
}
}
.category {
background: var(--tui-background-base-alt);
width: 100%;
padding: 15px;
.channel {
display: flex;
justify-content: start;
}
}
}

View File

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

View File

@@ -0,0 +1,77 @@
import {Component, inject, OnInit, signal} from '@angular/core';
import {ActivatedRoute, Router, RouterLink, RouterOutlet} from '@angular/router';
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';
import {DmStorage, NetworkStorage, ServiceManager} from '../../service-manager';
import {DMService} from '@chatenium/chatenium-sdk/services/dmService';
import {Network as NetworkData} from "@chatenium/chatenium-sdk/domain/networkService.schema"
import {NetworkService} from '@chatenium/chatenium-sdk/services/networkService';
import {IndexedDB} from '../../storage/indexed-db';
import {Navbar} from '../elements/navbar/navbar';
import {Oimg} from '../elements/oimg/oimg';
import {TUI_BREAKPOINT, TuiButton, TuiGroup, TuiIcon} from '@taiga-ui/core';
@Component({
selector: 'app-network',
imports: [
RouterOutlet,
Navbar,
Oimg,
TuiGroup,
TuiButton,
TuiIcon,
RouterLink
],
templateUrl: './network.html',
styleUrl: './network.scss',
})
export class Network implements OnInit {
serviceManager = inject(ServiceManager)
route = inject(ActivatedRoute)
indexedDb = inject(IndexedDB)
breakpoint = inject(TUI_BREAKPOINT)
router = inject(Router)
routerOutletActive = signal(false)
networkId = ""
get store() {
return this.serviceManager.networkServices()[this.networkId]
}
ngOnInit() {
this.route.params.subscribe(async params => {
console.log("Loading network...")
const networkId = params['networkId'];
this.networkId = networkId;
const session = this.serviceManager.currentSession();
const networkData = this.serviceManager.networks().find(c => c.networkId === networkId);
if (!session || !networkData) {
console.warn(`Initialization deferred for ${networkId}: Session or NetworkData missing.`);
return;
}
if (!this.serviceManager.networkServices()[networkId]) {
const newStore = {
networkData: signal<NetworkData>(networkData),
textChannels: signal({}),
wsListener: (action, data) => {
},
} as NetworkStorage;
newStore.service = new NetworkService(
session.userData.userid,
session.token,
networkId,
this.indexedDb.getApi(),
(action, data) => newStore.wsListener(action, data)
);
this.serviceManager.networkServices()[networkId] = newStore;
}
});
}
}

View File

@@ -0,0 +1,16 @@
<button (click)="router.navigate(['/chat/picture/'+serviceManager.currentSession()!.userData.userid])" tuiButton [appearance]="router.url == '/chat/picture/' + serviceManager.currentSession()!.userData.userid ? 'primary' : 'secondary'" iconStart="@tui.user">
{{"chat.chatnav.pictureList.myPictures"|translate}}
</button>
@for (chat of serviceManager.chats(); track chat.chatid) {
<button [class.enlarge]="breakpoint() == 'mobile'" tuiButton [appearance]="router.url == '/chat/picture/' + chat.userid ? 'primary' : 'flat'" [routerLink]="'/chat/picture/' + chat.userid">
<oimg [src]="chat.pfp" height="35px" width="35px" [radius]="10"></oimg>
<div class="info">
@if (chat.displayName == "") {
<span>{{'@'+chat.username}}</span>
} @else {
<span>{{chat.displayName}}</span>
}
</div>
</button>
}

View File

@@ -0,0 +1,28 @@
:host {
display: flex;
flex-direction: column;
gap: 5px;
button {
width: 100%;
display: flex;
justify-content: start;
font-weight: 600;
&.enlarge {
height: 75px;
}
.info {
display: flex;
flex-direction: column;
text-align: start;
.latest_message {
margin-top: -5px;
font-size: 12px;
opacity: 50%;
}
}
}
}

View File

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

View File

@@ -0,0 +1,47 @@
import {Component, inject, input, OnInit, signal} from '@angular/core';
import {ChatService} from '@chatenium/chatenium-sdk/services/chatService';
import {IndexedDB} from '../../storage/indexed-db';
import {Chat} from '@chatenium/chatenium-sdk/domain/chatService.schema';
import {TUI_BREAKPOINT, TuiButton} from '@taiga-ui/core';
import {Oimg} from '../elements/oimg/oimg';
import {Router, RouterLink} from '@angular/router';
import {TranslatePipe} from '@ngx-translate/core';
import {LoadStatus, ServiceManager} from '../../service-manager';
@Component({
selector: 'app-picture-list',
imports: [
TuiButton,
Oimg,
RouterLink,
TranslatePipe
],
templateUrl: './picture-list.html',
styleUrl: './picture-list.scss',
})
export class PictureList implements OnInit {
userid = input<string>("")
token = input<string>("")
indexedDb = inject(IndexedDB)
router = inject(Router)
serviceManager = inject(ServiceManager)
breakpoint = inject(TUI_BREAKPOINT)
async ngOnInit() {
this.serviceManager.chatService = new ChatService(this.userid(), this.token(), this.indexedDb.getApi(), () => {})
try {
this.serviceManager.chats.set(await this.serviceManager.chatService.getQuick())
this.serviceManager.chatsStatus.set(LoadStatus.updating)
} catch (e) {
console.warn(`Cache load failed: ${e}. Skipping cache load...`)
}
try {
this.serviceManager.chats.set(await this.serviceManager.chatService.get())
this.serviceManager.chatsStatus.set(LoadStatus.loaded)
} catch (e) {
console.error(e)
this.serviceManager.chatsStatus.set(LoadStatus.error)
}
}
}

View File

@@ -0,0 +1,61 @@
<ng-template [(tuiDialog)]="viewerOpen">
<oimg [src]="viewedPicture!.path" height="100%" width="100%" [radius]="0"></oimg>
</ng-template>
<main>
@defer (when store) {
<navbar backButtonDest="/chat">
<div class="data">
<oimg [src]="store.uploaderData().pfp" height="50px" width="50px" [radius]="15"></oimg>
<div class="uploader-data">
@if (!store.uploaderData().displayName) {
<span class="main-name">{{'@'+store.uploaderData().username}}</span>
} @else {
<span class="main-name">{{store.uploaderData().displayName}}</span>
<span class="alt-name">{{'@'+store.uploaderData().username}}</span>
}
</div>
</div>
<div class="items-right">
</div>
</navbar>
<main>
@if (openedAlbum) {
<div style="display: flex; align-items: center; gap: 10px">
<button tuiButton appearance="flat" iconStart="@tui.chevron-left" (click)="exitAlbum()"></button>
<h1 style="padding: 0; margin: 0">{{openedAlbum.name}}</h1>
</div>
<div id="pictureList">
@for (picture of openedAlbum.images; track picture) {
<oimg (click)="viewPicture(picture)" [src]="picture.path" height="200px" width="200px" [radius]="25" objectFit="none"></oimg>
}
</div>
} @else {
@if (store.albums().length == 0) {
<tui-block-status style="height: 300px">
<tui-icon icon="@tui.frown" tuiSlot="top" style="font-size: 150px"></tui-icon>
<h4>{{"chat.pictures.albumList.noAlbum"|translate}}</h4>
</tui-block-status>
} @else {
<div id="albumList">
@for (album of store.albums(); track album) {
<div class="album" (click)="enterAlbum(album.albumId)">
<masonry [maxColSize]="3" style="height: 300px; pointer-events: none;">
@for (file of album.images; track file) {
<img [src]="file.path+'_thumbnail.png'" style="filter: blur(5px)"/>
}
</masonry>
<div class="album-name">
<h2>{{album.name}}</h2>
</div>
</div>
}
</div>
}
}
</main>
}
</main>

View File

@@ -0,0 +1,85 @@
main {
height: 98svh;
display: grid;
grid-template-rows: 70px minmax(0, 1fr) auto;
padding: 15px;
navbar {
.uploader-data {
display: flex;
flex-direction: column;
.main-name {
font-size: 18px;
font-weight: bold;
}
.alt-name {
margin-top: -5px;
color: gray;
font-size: 12px;
}
}
.items-right {
margin-top: -10px;
button {
width: 35px;
height: 35px;
}
}
}
#albumList {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-content: flex-start;
.album {
width: 300px;
height: 300px;
background: var(--tui-background-base-alt);
border: 2px solid var(--tui-border-normal);
border-radius: 30px;
overflow: hidden;
position: relative;
cursor: pointer;
.album-name {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: color-mix(in srgb, var(--tui-background-base) 50%, transparent);
padding: 10px;
font-size: 18px;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
h2 {
padding: 0;
margin: 0;
}
}
}
}
#pictureList {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-content: flex-start;
overflow-y: scroll;
oimg {
background: var(--tui-background-base-alt);
height: 200px;
border-radius: 25px;
cursor: pointer;
}
}
}

View File

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

View File

@@ -0,0 +1,133 @@
import {Component, inject, signal} from '@angular/core';
import {DmStorage, PictureStorage, ServiceManager} from '../../../service-manager';
import {ActivatedRoute, Router} from '@angular/router';
import {IndexedDB} from '../../../storage/indexed-db';
import {TUI_BREAKPOINT, TuiButton, TuiDialog, TuiIcon} from '@taiga-ui/core';
import {PictureService} from '@chatenium/chatenium-sdk/services/pictureService';
import {Navbar} from '../../elements/navbar/navbar';
import {Oimg} from '../../elements/oimg/oimg';
import {Masonry} from '../../elements/masonry/masonry';
import {Album, Image} from '@chatenium/chatenium-sdk/domain/pictureService.schema';
import {pictureHandlers} from '@chatenium/chatenium-sdk/mocks/handlers/picture.http';
import {TuiBlockStatusComponent} from '@taiga-ui/layout';
import {TranslatePipe} from '@ngx-translate/core';
import {TuiBadgedContentDirective} from '@taiga-ui/kit';
@Component({
selector: 'app-see',
imports: [
Navbar,
Oimg,
TuiButton,
TuiIcon,
Masonry,
TuiDialog,
TuiBlockStatusComponent,
TranslatePipe,
TuiBadgedContentDirective
],
templateUrl: './see.html',
styleUrl: './see.scss',
})
export class See {
serviceManager = inject(ServiceManager)
route = inject(ActivatedRoute)
indexedDb = inject(IndexedDB)
breakpoint = inject(TUI_BREAKPOINT)
router = inject(Router)
uploaderId = ""
openedAlbum: Album | null = null
viewedPicture: Image | null = null
get viewerOpen() {
return this.viewedPicture != null
}
set viewerOpen(_: any) {
this.viewedPicture = null
this.router.navigate([], {
queryParams: { pictureId: null },
queryParamsHandling: "merge"
})
}
get store(): PictureStorage {
return this.serviceManager.pictureServices()[this.uploaderId]
}
enterAlbum(albumId: string) {
this.router.navigate([], {
queryParams: { albumId: albumId },
queryParamsHandling: "merge"
})
}
exitAlbum() {
this.openedAlbum = null
this.router.navigate([], {
queryParams: { albumId: null },
queryParamsHandling: "merge"
})
}
viewPicture(picture: Image) {
this.viewedPicture = picture
this.router.navigate([], {
queryParams: { pictureId: picture.imageId },
queryParamsHandling: "merge"
})
}
ngOnInit() {
this.route.params.subscribe(async params => {
const uploaderId = params['uploaderId'];
this.uploaderId = uploaderId;
const session = this.serviceManager.currentSession();
if (!session) {
return
}
if (!this.serviceManager.pictureServices()[uploaderId]) {
const newService = new PictureService(
session.token,
uploaderId,
session.userData.userid,
this.indexedDb.getApi(),
);
const uploaderInfo = await newService.get()
this.serviceManager.pictureServices.update(services => ({
...services,
[uploaderId]: {
albums: signal(uploaderInfo.pictures),
uploaderData: signal(uploaderInfo.userData),
service: newService,
} as PictureStorage
}));
// Show album
this.route.queryParams.subscribe(params => {
const albumId = params['albumId'];
if (albumId) {
const album = uploaderInfo.pictures.find(a => a.albumId === albumId)
if (album) {
this.openedAlbum = album
const pictureId = params['pictureId'];
if (pictureId) {
const picture = album.images.find(img => img.imageId === pictureId)
if (picture) {
this.viewedPicture = picture
}
}
}
}
})
}
});
}
protected readonly pictureHandlers = pictureHandlers;
}

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { authNeededGuard } from './auth-needed-guard';
describe('authNeededGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authNeededGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import {CanActivateFn, Router} from '@angular/router';
import {inject} from '@angular/core';
import {IndexedDB} from '../storage/indexed-db';
import {Keyring} from '../storage/keyring';
import {KeyValue} from '../storage/key-value';
import {SessionManager} from '@chatenium/chatenium-sdk/services/sessionManager';
export const authNeededGuard: CanActivateFn = (route, state) => {
const indexedDb = inject(IndexedDB)
const keyring = inject(Keyring)
const keyValue = inject(KeyValue)
const router = inject(Router)
indexedDb.openDatabase().then(async () => {
const sessionManager = new SessionManager(indexedDb.getApi(), keyring.getApi(), keyValue.getApi());
const sessions = await sessionManager.loadSessions()
if (sessions.length == 0) {
router.navigate(['/signin'])
}
})
return true;
};

View File

@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';
import { noAuthGuard } from './no-auth-guard';
describe('noAuthGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => noAuthGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import {CanActivateFn, Router} from '@angular/router';
import {SessionManager} from '@chatenium/chatenium-sdk/services/sessionManager';
import {inject} from '@angular/core';
import {IndexedDB} from '../storage/indexed-db';
import {Keyring} from '../storage/keyring';
import {KeyValue} from '../storage/key-value';
export const noAuthGuard: CanActivateFn = async (route, state) => {
const indexedDb = inject(IndexedDB)
const keyring = inject(Keyring)
const keyValue = inject(KeyValue)
const router = inject(Router)
indexedDb.openDatabase().then(async () => {
const sessionManager = new SessionManager(indexedDb.getApi(), keyring.getApi(), keyValue.getApi());
const sessions = await sessionManager.loadSessions()
if (sessions.length != 0) {
router.navigate(['/chat'])
}
})
return true;
};

View File

@@ -0,0 +1,268 @@
@if (cookieConsentOpen) {
<div id="cookieConsent" tuiCardLarge>
<h1>
<tui-icon icon="@tui.cookie"></tui-icon>
{{'home.cookies'|translate}}
</h1>
<p>{{'home.cookiesDesc'|translate}}</p>
<button (click)="consent()" tuiButton iconStart="@tui.check">
{{'home.understood'|translate}}
</button>
</div>
}
<ng-template [(tuiDialog)]="androidBetaTestDialogOpen"
[tuiDialogOptions]="{label: translateService.instant('home.requestAlphaAccess'), size: 's'}">
<span>{{'home.joinGoogleGroupWhy'|translate}}</span>
<button (click)="enterGGroup()" style="width: 100%; margin-top: 10px" tuiButton iconStart="@tui.users">{{'home.joinGoogleGroup'|translate}}</button>
</ng-template>
<ng-template [(tuiDialog)]="appleBetaTestDialogOpen"
[tuiDialogOptions]="{label: translateService.instant('home.requestAlphaAccess'), size: 's'}">
<span>{{'home.testFlightDesc'|translate}}</span>
<button (click)="openTestFlight()" style="width: 100%; margin-top: 10px" tuiButton >
<i class="fa-brands fa-apple" style="font-size: 25px"></i>
{{'home.testFlight'|translate}}
</button>
</ng-template>
<ng-template [(tuiDialog)]="msBetaTestDialogOpen"
[tuiDialogOptions]="{label: translateService.instant('home.requestAlphaAccess'), size: 's'}">
<span>{{'home.microsoftStoreDesc'|translate}}</span>
<button (click)="openMsStore()" style="width: 100%; margin-top: 10px" tuiButton >
<i class="fa-brands fa-microsoft" style="font-size: 25px"></i>
{{'home.microsoftStore'|translate}}
</button>
</ng-template>
<main>
<section id="hero">
<div id="imgScroller" [style]="'opacity:'+scrollerOpacity">
<img [src]="'/images/homepage/new_customi_'+theme+'.png'" alt="ARYA_BLUE_IMAGES">
<img [src]="'/images/homepage/new_dm_'+theme+'.png'" alt="ARYA_BLUE_IMAGES">
<img [src]="'/images/homepage/new_network_'+theme+'.png'" alt="ARYA_BLUE_IMAGES">
<img [src]="'/images/homepage/new_network_discovery_'+theme+'.png'" alt="ARYA_BLUE_IMAGES">
<img [src]="'/images/homepage/new_picture_stats_'+theme+'.png'" alt="ARYA_BLUE_IMAGES">
<img [src]="'/images/homepage/new_pictures_'+theme+'.png'" alt="ARYA_BLUE_IMAGES">
</div>
<div id="introducer">
<svg viewBox="0 0 350 60" width="1000px" height="200px">
<text class="logo" x="50%" y="80%" text-anchor="middle">Chatenium</text>
</svg>
<div id="quickFeatures">
<h1>
{{ 'aChatProgram'|translate }}
</h1>
<p class="chtn_desc">
{{ 'whatIsChtn'|translate }}
</p>
<button style="width: 350px" routerLink="/chat" tuiButton iconStart="@tui.globe">
{{'home.enterChtnOnWeb'|translate}}
</button>
<button style="width: 350px" appearance="secondary" (click)="openRoadmap()" tuiButton iconStart="@tui.chart-no-axes-gantt">
Roadmap
</button>
<p style="display: flex; align-items: center; gap: 5px;">
<tui-icon icon="@tui.mouse"></tui-icon>
{{ 'scrollDownForMore'|translate }}
</p>
</div>
</div>
</section>
<section class="detailedFeature">
<div class="style">
<h1>{{ 'home.chtn'|translate }} <span style="color: var(--tui-status-negative)"><tui-icon icon="@tui.circle-gauge"></tui-icon>
{{ 'home.adaptsToYou'|translate }}</span></h1>
<p>{{ 'home.adaptsToYouDesc'|translate }}</p>
<div class="cardList">
<div tuiCardLarge>
<tui-icon icon="@tui.globe" style="font-size: 80px"></tui-icon>
<h2>{{ 'home.chtnOnWeb'|translate }}</h2>
<p>{{ 'home.chtnOnWebDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faAndroid"></fa-icon>
<h2>{{ 'home.chtnOnAndroid'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{ 'home.chtnOnAndroidDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faApple"></fa-icon>
<h2>{{ 'home.chtnOnApple'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{ 'home.chtnOnAppleDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faWindows"></fa-icon>
<h2>{{ 'home.chtnOnWindows'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{ 'home.chtnOnWindowsDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faLinux"></fa-icon>
<h2>{{ 'home.chtnOnLinux'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{ 'home.chtnOnLinuxDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faRecordVinyl"></fa-icon>
<h2>{{ 'home.chtnEcho'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{ 'home.chtnEchoDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faDesktop"></fa-icon>
<h2>{{ 'home.chtnReson'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{ 'home.chtnResonDesc'|translate }}</p>
</div>
</div>
</div>
</section>
<section class="detailedFeature">
<div class="style">
<h1>{{ 'home.chtnIs'|translate }} <span style="color: var(--tui-status-positive)"><tui-icon icon="@tui.lock"></tui-icon>
{{ 'home.secure'|translate }}</span></h1>
<p>{{ 'home.secureDesc'|translate }}</p>
<div class="cardList">
<div tuiCardLarge>
<tui-icon icon="@tui.file"></tui-icon>
<h2>{{ 'home.secureCdn'|translate }}</h2>
<p>{{ 'home.secureCdnDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<tui-icon icon="@tui.message-circle"></tui-icon>
<h2>{{ 'home.encryptedText'|translate }}</h2>
<p>{{ 'home.encryptedTextDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<tui-icon icon="@tui.shredder"></tui-icon>
<h2>{{ 'home.zeroDataCollection'|translate }}</h2>
<p>{{ 'home.zeroDataCollectionDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<tui-icon icon="@tui.phone"></tui-icon>
<h2>{{ 'home.secureCalling'|translate }}</h2>
<p>{{ 'home.secureCallingDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<tui-icon icon="@tui.footprints"></tui-icon>
<h2>{{ 'home.zeroDigitalFootprint'|translate }}</h2>
<p>{{ 'home.zeroDigitalFootprintDesc'|translate }}</p>
</div>
</div>
</div>
</section>
<section class="detailedFeature" style="margin-bottom: 5%;">
<div class="style">
<h1>{{ 'home.weAreExcitedFor'|translate }} <span style="color: var(--tui-status-negative)"><tui-icon icon="@tui.heart"></tui-icon>
{{ 'home.you'|translate }}</span></h1>
<p>{{ 'home.weAreExcitedForYouDesc'|translate }}</p>
<div class="cardList">
<div tuiCardLarge>
<tui-icon icon="@tui.globe" style="font-size: 80px"></tui-icon>
<h2><a routerLink="/chat">{{ 'home.enterChtnOnWeb'|translate }}</a></h2>
<p>{{ 'home.enterChtnOnWebDesc'|translate }}</p>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faGooglePlay"></fa-icon>
<h2>{{ 'home.downloadChtnOnAndroid'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{'home.openBetaDesc'|translate}}</p>
<button (click)="androidBetaTestDialogOpen = true" tuiButton iconStart="@tui.door-open">{{'home.requestAlphaAccess'|translate}}</button>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faAppStoreIos"></fa-icon>
<h2>{{ 'home.downloadChtnOnApple'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{'home.openBetaDesc'|translate}}</p>
<button (click)="appleBetaTestDialogOpen = true" tuiButton iconStart="@tui.door-open">{{'home.requestAlphaAccess'|translate}}</button>
</div>
<div tuiCardLarge>
<fa-icon [icon]="faMicrosoft"></fa-icon>
<h2>{{ 'home.downloadOnWindows'|translate }}</h2>
<div tuiBadge>{{'home.openBeta'|translate}}</div>
<p>{{'home.openBetaDesc'|translate}}</p>
<button (click)="msBetaTestDialogOpen = true" tuiButton iconStart="@tui.door-open">{{'home.requestAlphaAccess'|translate}}</button>
</div>
</div>
</div>
</section>
</main>
<footer>
<h1>Chatenium</h1>
<span>2026 &copy;</span>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button routerLink="/privacy" tuiButton iconStart="@tui.lock">
{{'home.privacyPolicy'|translate}}
</button>
<button routerLink="/tos" tuiButton iconStart="@tui.scroll">
{{'home.tos'|translate}}
</button>
<button (click)="openHelp()" tuiButton iconStart="@tui.badge-question-mark">
{{'home.help'|translate}}
</button>
<button (click)="openBlog()" tuiButton iconStart="@tui.scroll-text">
{{'home.blog'|translate}}
</button>
<button (click)="openAPISpecs()" tuiButton iconStart="@tui.braces">
{{'home.apiSpecs'|translate}}
</button>
</div>
</footer>

View File

@@ -0,0 +1,167 @@
* {
font-family: kinnBook, serif !important;
}
main {
#hero {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 100vh;
#imgScroller {
position: fixed;
top: -500px;
left: 0;
transform: rotateX(35deg);
animation: scrollerAnim 30s infinite ease-in-out;
filter: blur(15px);
opacity: 0.5;
pointer-events: none;
img {
width: 100%;
}
}
#introducer {
z-index: 9999;
font-family: kinnBook, serif;
#quickFeatures {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
flex-direction: column;
h1 {
font-size: 25px;
}
p {
font-size: 18px;
}
.chtn_desc {
width: 500px;
}
}
}
@keyframes scrollerAnim {
0% {
transform: translateY(450px);
}
50% {
transform: translateY(-450px);
}
100% {
transform: translateY(450px);
}
}
}
.detailedFeature {
z-index: 9999;
padding: 50px 15% 50px 15%;
.style {
background: var(--tui-background-base-alt);
width: 100%;
padding: 20px 20px 20px 50px;
border-radius: 50px;
.cardList {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 10px;
div {
p {
font-size: 15px;
}
h2 {
display: flex;
align-items: center;
gap: 5px;
margin: 0;
}
i, fa-icon {
font-size: 75px;
}
a {
color: var(--tui-background-accent-1);
text-decoration: none;
}
}
}
}
h1 {
font-size: 50px;
font-weight: bolder;
}
tui-icon {
font-size: 50px;
}
p {
font-size: 20px;
}
}
}
.logo {
font-size: 55px;
stroke: var(--tui-background-accent-1);
fill: transparent;
font-family: kinnBook, serif !important;
font-weight: bold;
stroke-width: 2;
stroke-dasharray: 50 20; /* Dashes and gaps */
stroke-dashoffset: 0;
animation: moveStroke 2s linear infinite;
}
@keyframes moveStroke {
to {
stroke-dashoffset: -70; /* Negative offset moves the dashes endlessly */
}
}
footer {
height: 25svh;
width: 100vw;
background: var(--tui-background-base-alt);
padding: 50px;
p {
font-size: 25px;
font-family: kinnBook, serif;
font-weight: bold;
}
}
#cookieConsent {
position: fixed;
right: 25px;
bottom: 25px;
z-index: 9999;
width: 350px;
height: 300px;
h1 {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
}
}

View File

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

View File

@@ -0,0 +1,153 @@
import {Component, HostListener, inject} from '@angular/core';
import {Router, RouterLink} from '@angular/router';
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {TUI_DARK_MODE, TuiAppearance, TuiButton, TuiDialog, TuiIcon} from '@taiga-ui/core';
import {TuiCardLarge} from '@taiga-ui/layout';
import {TuiBadge} from '@taiga-ui/kit';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {
faAndroid,
faApple,
faAppStoreIos,
faGooglePlay,
faLinux,
faMicrosoft,
faWindows
} from '@fortawesome/free-brands-svg-icons';
import {faDesktop, faRecordVinyl} from '@fortawesome/free-solid-svg-icons';
@Component({
selector: 'app-homepage',
imports: [
TranslatePipe,
TuiButton,
RouterLink,
TuiIcon,
TuiCardLarge,
TuiDialog,
TuiAppearance,
TuiBadge,
FaIconComponent
],
templateUrl: './homepage.html',
styleUrl: './homepage.scss',
})
export class Homepage {
public router = inject(Router);
translateService = inject(TranslateService)
darkMode = inject(TUI_DARK_MODE)
get theme(): string {
return this.darkMode() ? "dark" : "light"
}
androidBetaTestDialogOpen = false;
appleBetaTestDialogOpen = false;
msBetaTestDialogOpen = false;
cookieConsentOpen = false;
mainFeatureIndex = 0
features: { icon: string, title: string, color: string }[] = [
{
icon: "circle-gauge",
title: "fast",
color: "#ff3d32"
},
{
icon: "lock",
title: "secure",
color: "#22c55e"
},
{
icon: "speech",
title: "independent",
color: "#326fd1"
}
]
scrollerOpacity = 1;
@HostListener('window:scroll', ['$event'])
onScroll(event: Event) {
const scrollTop = window.scrollY;
const docHeight = document.getElementById("hero")?.clientHeight ?? 0;
let scrollPercent = scrollTop / docHeight;
if (scrollPercent > 1) scrollPercent = 1; // clamp at max
this.scrollerOpacity = 1 - scrollPercent; // invert
}
contact() {
const mail = 'mailto:personal@alms.hu';
const a = document.createElement('a');
a.href = mail;
a.click();
}
enterGGroup() {
window.open('https://groups.google.com/g/chatenium-closed-alpha-testing', '_blank');
}
openTestFlight() {
window.open('https://testflight.apple.com/join/ATGmZ8mx', '_blank')
}
openRoadmap() {
window.open('https://help.chatenium.hu/s/169154db-df3e-44cb-980d-2db1915ecdf8', '_blank')
}
openMsStore() {
window.open('https://apps.microsoft.com/detail/9p1xq5vb62b0?ocid=webpdpshare', '_blank')
}
openBlog() {
window.open('https://blog.chatenium.hu', '_blank')
}
openHelp() {
window.open('https://help.chatenium.hu/s/06a56637-0050-4332-b397-ea3ca8fa91a5', '_blank')
}
openAPISpecs() {
window.open('https://apispecs.chatenium.hu', '_blank')
}
gotoFunctions() {
location.href = "#functions"
}
stepFeature = () => {
console.log(this.features, this.mainFeatureIndex + 1, this.mainFeatureIndex);
if (this.features && this.features[this.mainFeatureIndex + 1] !== undefined) {
this.mainFeatureIndex++;
} else {
this.mainFeatureIndex = 0;
}
};
consent() {
localStorage.setItem("cookieConsentRead", "true")
this.cookieConsentOpen = false;
}
ngOnInit(): void {
const consentOk = localStorage.getItem("cookieConsentRead")
if (!consentOk) {
this.cookieConsentOpen = true
}
}
protected readonly window = window;
protected readonly scroll = scroll;
protected readonly localStorage = localStorage;
protected readonly faApple = faApple;
protected readonly faAndroid = faAndroid;
protected readonly faWindows = faWindows;
protected readonly faLinux = faLinux;
protected readonly faRecordVinyl = faRecordVinyl;
protected readonly faDesktop = faDesktop;
protected readonly faGooglePlay = faGooglePlay;
protected readonly faAppStoreIos = faAppStoreIos;
protected readonly faMicrosoft = faMicrosoft;
}

View File

@@ -0,0 +1,131 @@
<div style="padding: 15px;">
<button tuiButton iconStart="@tui.arrow-left" (click)="router.navigate(['/'])">Back to homepage</button>
<h1>
<tui-icon icon="@tui.lock"></tui-icon>
Chatenium's Privacy Policy
</h1>
<section tuiCardLarge style="background: var(--tui-background-base-alt)">
<h2>Welcome!</h2>
<span>This Privacy Policy shows how we store and protect your personal information through our services.</span><br>
<ul>
<li><b>We care about your privacy.</b> We are committed to creating an extra secure message platform where people's
data is safe and not shared.
</li>
<li><b>We do not sell your personal data.</b> We are a non-profit group. We do not share your personal data with
other companies.
</li>
<li><b>We collect the bare minimum of data we need at login.</b> When you log into your account we collect your browser's
version,
operating system's name and your selected language inside Chatenium for account security. (Logging out will clear stored data from our database)
</li>
</ul>
</section>
<section tuiCardLarge style="background: var(--tui-background-base-alt)">
<h2>All the information we collect</h2>
<h3><em>Information that you provide</em></h3>
<ul>
<li><b>Account information.</b> When you register to Chatenium you can either use social logins, or provide a
username, display name, password and an e-mail address. When using social logins, we collect your e-mail address
to manage your account and prevent duplicates.
</li>
<li><b>Content you upload or send.</b> This includes everything you upload to our services (media, message,
etc...).
</li>
</ul>
<h3><em>Information we collect automatically</em></h3>
<ul>
<li><b>Information about your device.</b> As mentioned earlier, we collect your browser's information, operating
system's information and the current selected language for security reasons.
</li>
</ul>
</section>
<section tuiCardLarge style="background: var(--tui-background-base-alt)">
<h2>How we handle client-side storage</h2>
<ul>
<li>
<b>Cookies.</b>
<ul>
<li>Your authorization token is stored here.</li>
<li>We do <strong>not</strong> use cookies for analytics or to track you.</li>
</ul>
</li>
<br>
<li>
<b>LocalStorage.</b> Used to enhance user experience by saving settings made by the user like:
<ul>
<li>Selected language</li>
<li>Selected microphone</li>
<li>Selected camera</li>
<li>Selected theme</li>
</ul>
</li>
<br>
<li>
<b>IndexedDB.</b> Used to boost performance and enable offline support via PWA.
<ul>
<li>Messages</li>
<li>Files</li>
</ul>
</li>
</ul>
</section>
<section tuiCardLarge style="background: var(--tui-background-base-alt)">
<h2>How we use your data</h2>
<ul>
<li><b>To provide you with services.</b> For example when you start a call, WebRTC collects your audio / camera /
screen information to make it work.
</li>
<li><b>To contact you.</b> We use your e-mail address to contact you whenever needed. E-mail can also be used for
account recovery.
</li>
<li><b>Data sharing.</b> We do <strong>not</strong> share <strong>any</strong> data with third parties.
</li>
</ul>
</section>
<section tuiCardLarge style="background: var(--tui-background-base-alt)">
<h2>How we protect your information.</h2>
<ul>
<li><b>Secure communication.</b> All data between the client and the server is encrypted using Transport Layer
Security (TLS).
</li>
<li><b>Secure data storage.</b> After communication is finished, the messages and the files are encrypted before
storing it.
</li>
</ul>
</section>
<section tuiCardLarge style="background: var(--tui-background-base-alt)">
<span>When editing or deleting a messages or a file we will permanently delete them from our database and client side caches.</span><br>
<br>
<em>Last modified: December 15. 2025</em><br>
<span>Chatenium &copy;</span>
</section>
</div>

View File

@@ -0,0 +1,3 @@
section {
margin: 10px;
}

View File

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

View File

@@ -0,0 +1,18 @@
import {Component, inject} from '@angular/core';
import {Router} from '@angular/router';
import {TuiCardLarge} from '@taiga-ui/layout';
import {TuiButton, TuiIcon} from '@taiga-ui/core';
@Component({
selector: 'app-privacy',
imports: [
TuiCardLarge,
TuiIcon,
TuiButton
],
templateUrl: './privacy.html',
styleUrl: './privacy.scss',
})
export class Privacy {
public router = inject(Router);
}

View File

@@ -9,6 +9,13 @@ 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';
import {NetworkService} from '@chatenium/chatenium-sdk/services/networkService';
import {Network, NetworkCategory, NetworkChannel} from '@chatenium/chatenium-sdk/domain/networkService.schema';
import {TextChannelServiceService} from '@chatenium/chatenium-sdk/services/textChannelService';
import {Message as NetworkMessage} from '@chatenium/chatenium-sdk/domain/textChannelService.schema';
import {PictureService} from '@chatenium/chatenium-sdk/services/pictureService';
import {Album} from '@chatenium/chatenium-sdk/domain/pictureService.schema';
import {PublicUserData} from '@chatenium/chatenium-sdk/domain/common.schema';
@Injectable({
providedIn: 'root',
@@ -21,11 +28,18 @@ export class ServiceManager {
sessionManager = new SessionManager(this.database.getApi(), this.keyring.getApi(), this.keyValue.getApi())
currentSession = signal<Session | null>(null)
chatService: ChatService | null = null // Initialized in dm-list.ts
chatService: ChatService | null = null // Initialized in picture-list.ts
chatsStatus = signal<LoadStatus>(LoadStatus.loading)
chats = signal<Chat[]>([])
networkService: NetworkService | null = null // Initialized in network-list.ts
networksStatus = signal<LoadStatus>(LoadStatus.loading)
networks = signal<Network[]>([])
// Services for individual networks
networkServices = signal<Record<string, NetworkStorage>>({})
dmServices = signal<Record<string, DmStorage>>({})
pictureServices = signal<Record<string, PictureStorage>>({})
}
export enum LoadStatus {
@@ -42,3 +56,25 @@ export interface DmStorage {
messageBox: MessageBoxViewModel
wsListener: (action: string, message: string) => void
}
export interface PictureStorage {
service: PictureService
albums: WritableSignal<Album[]>
uploaderData: WritableSignal<PublicUserData>
}
export interface NetworkStorage {
service: NetworkService
networkData: WritableSignal<Network>
wsListener: (action: string, message: string) => void
textChannels: WritableSignal<Record<string, TextChannelStorage>>
}
export interface TextChannelStorage {
service: TextChannelServiceService
messages: WritableSignal<NetworkMessage[]>
channelData: WritableSignal<NetworkChannel>
categoryData: WritableSignal<NetworkCategory>
messageBox: MessageBoxViewModel
wsListener: (action: string, message: string) => void
}

View File

@@ -9,6 +9,7 @@ import {SessionManager} from '@chatenium/chatenium-sdk/services/sessionManager';
import {ServiceManager} from '../service-manager';
import {IndexedDB} from '../storage/indexed-db';
import {PublicUserData} from '@chatenium/chatenium-sdk/domain/common.schema';
import {Router} from '@angular/router';
@Component({
selector: 'app-signin',
@@ -30,6 +31,7 @@ import {PublicUserData} from '@chatenium/chatenium-sdk/domain/common.schema';
})
export class SignIn implements OnInit {
indexedDb = inject(IndexedDB)
router = inject(Router)
service = new AuthService()
serviceManager = new ServiceManager()
@@ -68,6 +70,7 @@ export class SignIn implements OnInit {
displayName: resp.displayName,
}, resp.token)
console.log("Logged in")
this.router.navigate(["/chat"])
} catch (e) {
console.error(e)
this.signInForm.controls['password'].setErrors({incorrect: true})

Some files were not shown because too many files have changed in this diff Show More