11 Commits

Author SHA1 Message Date
5f032c5098 Finished implementing user settings -> security 2026-04-19 12:37:52 +02:00
20e67ecd85 Started implementing user settings -> security -> e-mail management 2026-04-18 19:28:51 +02:00
4eaaacac2c Started implementing user settings -> security -> password management 2026-04-18 15:24:37 +02:00
8afd4a81b0 3.0 Beta 8 2026-04-17 17:55:42 +02:00
a0a6fdaf55 3.0 Beta 7 2026-04-15 17:35:55 +02:00
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
139 changed files with 5385 additions and 561 deletions

View File

@@ -1,2 +1,2 @@
# Chatenium On Web # Chatenium Nexum
A modern web application for Chatenium compatible with a wide range of devices. The next generation Web application for Chatenium, built on modern standards powered by Chatenium SDK (TypeScript).

View File

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

672
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/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@chatenium/chatenium-sdk": "^1.1.3", "@angular/service-worker": "^21.2.0",
"@chatenium/chatenium-sdk": "^1.2.2",
"@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/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0",
"@taiga-ui/addon-charts": "^5.1.0", "@taiga-ui/addon-charts": "^5.1.0",
@@ -26,9 +30,11 @@
"@taiga-ui/addon-table": "^5.1.0", "@taiga-ui/addon-table": "^5.1.0",
"@taiga-ui/cdk": "^5.1.0", "@taiga-ui/cdk": "^5.1.0",
"@taiga-ui/core": "^5.1.0", "@taiga-ui/core": "^5.1.0",
"@taiga-ui/i18n": "^5.2.0",
"@taiga-ui/icons": "^5.1.0", "@taiga-ui/icons": "^5.1.0",
"@taiga-ui/kit": "^5.1.0", "@taiga-ui/kit": "^5.1.0",
"@taiga-ui/layout": "^5.1.0", "@taiga-ui/layout": "^5.1.0",
"libphonenumber-js": "^1.12.41",
"ngx-cookie-service": "^21.3.1", "ngx-cookie-service": "^21.3.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.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,83 @@
{ {
"version": "Chatenium Nexum 3.0 Beta 9 (April 19, 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": {
"soon": "Coming soon...",
"chtn": "Chatenium ",
"chtnIs": "Chatenium is ",
"help": "Help",
"blog": "Blog",
"apiSpecs": "API specifications",
"adaptsTo": "Chatenium adapts to ",
"adaptsToYouDesc": "Choose your 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": { "signIn": {
"formTitle": "Sign in", "formTitle": "Sign in",
"mainInput": { "mainInput": {
@@ -18,7 +97,135 @@
} }
}, },
"chat": { "chat": {
"userSettingsDialog": {
"label": "Settings",
"options": {
"security": "Privacy & Security",
"profile": "Profile",
"themes": "Themes",
"sessions": "Sessions",
"storage": "Data and Storage"
},
"security": {
"changePasswordDialog": {
"label": "Change password",
"labelSet": "Set password",
"labelRemove": "Remove password",
"warn": "After you change your password, you will be logged out of all your sessions.",
"currentPassword": "Current password",
"newPassword": "New password",
"newPasswordRepeat": "Repeat new password"
},
"changeEmailDialog": {
"label": "Change e-mail address",
"labelSet": "Set e-mail address",
"labelRemove": "Remove e-mail address",
"warn": "After you change your e-mail address, you will be logged out of all your sessions.",
"newMail": "New e-mail address",
"currentPassword": "Current password",
"oldCode": "Code sent to the old e-mail address",
"newCode": "Code sent to the new e-mail address"
},
"changePhoneDialog": {
"label": "Change phone number",
"labelSet": "Set phone number",
"labelRemove": "Remove phone number",
"warn": "After you change your phone number, you will be logged out of all your sessions.",
"newPhone": "New phone number",
"currentPassword": "Current password",
"oldCode": "Code sent to the old phone number",
"newCode": "Code sent to the new phone number"
},
"label": "Keep your account safe by using as much sign in methods as possible. Also check your credentials regularly to keep them up to date.",
"password": "Password",
"set": "Set",
"notSet": "Not set",
"changePassword": "Change password",
"phoneNumber": "Phone number",
"email": "E-mail address",
"removePassword": "Remove password",
"changePhone": "Change phone number",
"changeMail": "Change e-mail address",
"removePhone": "Remove phone number",
"removeMail": "Remove e-mail address",
"setPassword": "Set password",
"setPhone": "Set phone number",
"setMail": "Set e-mail address"
}
},
"tabBar": {
"tab1": "Chats",
"tab2": "Networks",
"tab3": "Pictures",
"tab4": "Settings"
},
"network": {
"tabs": {
"channels": "Channels",
"members": "Members",
"settings": "Settings"
},
"settings": {
"options": {
"categories": {
"apps": "Apps",
"moderation": "Moderation",
"community": "Community"
},
"overview": "Overview",
"rank": "Ranks",
"emoji": "Emojis",
"embed": "Embed",
"invite": "Invites",
"webhook": "Webhooks",
"bots": "Bots",
"activityHistory": "Activity history",
"bans": "Bans",
"communityChannels": "Community channels",
"networkIntroducer": "Network introducer",
"members": "Members"
},
"overviewPage": {
"networkPicture": "Network picture",
"networkName": "Network name",
"networkVisibility": "Network visibility",
"uploadNewPicture": "Upload new picture",
"setNewName": "Set new name",
"changeToPrivate": "Change to private",
"changeToPrivateDialog": {
"label": "You are about to make your network private",
"warn": {
"1": "Your network will be delisted from Network Discovery.",
"2": "Users will no longer be able to freely join your network without an invite.",
"3": "Your broadcasts may be unavailable to users outside your network."
}
},
"changeToPublic": "Change to public",
"changeToPublicDialog": {
"label": "You are about to make your network public",
"warn": {
"1": "Your network will be listed in Network Discovery.",
"2": "Users will be able to freely join your network without an invite.",
"3": "Your broadcasts will be available to users outside your network."
}
},
"setNewNameDialog": {
"label": "New name"
}
}
}
},
"changeLogDialog": {
"label": "Chatenium has been updated",
"changeLog": {
"1": "Started implementing user settings, Privacy & Security is now available.",
"2": "Enabled Chatenium Pictures on mobile devices."
}
},
"chatnav": { "chatnav": {
"pictureList": {
"myPictures": "My pictures"
},
"dmList": { "dmList": {
"newChat": "Start new chat", "newChat": "Start new chat",
"messageBox": { "messageBox": {
@@ -26,10 +233,20 @@
"you": "You: " "you": "You: "
} }
} }
},
"networkList": {
"newNetwork": "Start new network"
}
},
"pictures": {
"albumList": {
"noAlbum": "This user has no albums published"
} }
}, },
"elements": { "elements": {
"messageBox": { "messageBox": {
"editMessageLabel": "Editing message: ",
"attachments": "Attachment(s)",
"placeholder": "Type a message...", "placeholder": "Type a message...",
"message": "Message", "message": "Message",
"uplDrag": { "uplDrag": {
@@ -39,6 +256,12 @@
"fileUploadDialog": { "fileUploadDialog": {
"label": "Upload files" "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,20 @@
import {provideTaiga} from '@taiga-ui/core'; import {provideTaiga} from '@taiga-ui/core';
import {ApplicationConfig, provideBrowserGlobalErrorListeners} from '@angular/core'; import {ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode} from '@angular/core';
import {provideRouter} from '@angular/router'; import {provideRouter, withRouterConfig} from '@angular/router';
import {routes} from './app.routes'; import {routes} from './app.routes';
import {provideTranslateService} from '@ngx-translate/core'; import {provideTranslateService} from '@ngx-translate/core';
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader'; import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
import {provideServiceWorker} from '@angular/service-worker';
import {tuiInputPhoneInternationalOptionsProvider} from '@taiga-ui/kit';
import {defer} from 'rxjs';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes, withRouterConfig({
paramsInheritanceStrategy: "always"
})),
provideTaiga(), provideTaiga(),
provideTranslateService({ provideTranslateService({
fallbackLang: "en", fallbackLang: "en",
@@ -18,6 +23,15 @@ export const appConfig: ApplicationConfig = {
prefix: "/i18n/", prefix: "/i18n/",
suffix: ".json" suffix: ".json"
}) })
}) }),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
}),
tuiInputPhoneInternationalOptionsProvider({
metadata: defer(async () =>
import('libphonenumber-js/max/metadata').then((m) => m.default),
),
}),
], ],
}; };

View File

@@ -2,12 +2,29 @@ import {Routes} from '@angular/router';
import {SignIn} from './signin/signin'; import {SignIn} from './signin/signin';
import {Chat} from './chat/chat'; import {Chat} from './chat/chat';
import {Dm} from './chat/dm/dm'; 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 = [ 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: '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,30 @@
<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>
<ng-template [(tuiDialog)]="userSettingsOpen" [tuiDialogOptions]="{closable: false, appearance: 'taiga big'}" class="user-settings">
<user-settings (close)="userSettingsOpen.set(false)"/>
</ng-template>
@if (serviceManager.currentSession() == null) { @if (serviceManager.currentSession() == null) {
<main style="width: 100%; height: 100svh; display: flex; justify-content: center; align-items: center;"> <main style="width: 100%; height: 100svh; display: flex; justify-content: center; align-items: center;">
<tui-loader size="xl"/> <tui-loader size="xl"/>
</main> </main>
} @else { } @else {
@if (breakpoint() != "mobile") {
<main id="layout" tuiGroup [collapsed]="true"> <main id="layout" tuiGroup [collapsed]="true">
<aside id="chatnav"> <aside id="chatnav">
<aside> <aside>
<tui-segmented id="mode_switcher"> <tui-segmented id="mode_switcher" [(activeItemIndex)]="navigationActiveIndex">
<button> <button>
<tui-icon icon="@tui.message-circle"/> <tui-icon icon="@tui.message-circle"/>
</button> </button>
@@ -18,22 +36,99 @@
</button> </button>
</tui-segmented> </tui-segmented>
<button id="bottom_btn" tuiButton appearance="flat"> <button id="bottom_btn" tuiButton [appearance]="userSettingsOpen() ? 'primary' : 'flat'" (click)="userSettingsOpen.set(true)">
<tui-icon icon="@tui.cog"/> <tui-icon icon="@tui.cog"/>
</button> </button>
</aside> </aside>
<main> <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> </main>
</aside> </aside>
<main id="content"> <main id="content">
<div id="content_tint"> <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) { @defer (when serviceManager.chatsStatus() != LoadStatus.loading) {
<router-outlet/> <router-outlet/>
} }
} @else if (router.url.startsWith("/chat/network")) {
@defer (when serviceManager.networksStatus() != LoadStatus.loading) {
<router-outlet/>
}
} @else {
<router-outlet/>
}
</div> </div>
</main> </main>
</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>
}
@case (2) {
<app-picture-list [token]="serviceManager.currentSession()!.token"
[userid]="serviceManager.currentSession()!.userData.userid"></app-picture-list>
}
@case (3) {
<user-settings/>
}
}
</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") || router.url.startsWith("/chat/picture")) {
<!-- 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 { #chatnav {
display: grid; display: grid;
grid-template-columns: 70px minmax(0, 1fr); grid-template-columns: 70px minmax(0, 1fr);
height: 100svh;
aside { aside {
padding: 15px; padding: 15px;
@@ -25,6 +26,12 @@
button { button {
height: 50px; height: 50px;
tui-icon {
&::before {
color: var(--tui-background-accent-1);
}
}
} }
} }
@@ -41,18 +48,45 @@
border-radius: 20px 0 0 20px; border-radius: 20px 0 0 20px;
margin: 10px 0 10px 10px; margin: 10px 0 10px 10px;
padding: 15px; padding: 15px;
overflow-y: scroll;
} }
} }
#content { #content {
width: 100%; width: 100%;
height: 100svh;
padding: 10px 10px 10px 0; padding: 10px 10px 10px 0;
#content_tint { #content_tint {
border-radius: 0 20px 20px 0; border-radius: 0 20px 20px 0;
height: 100%; height: 100%;
background: var(--tui-background-neutral-2); 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 {
overflow-y: scroll;
height: 100%;
padding: 20px;
}
}
}
::ng-deep tui-dialog[data-appearance~=big] {
height: 90svh;
width: 90svw !important;
}

View File

@@ -1,13 +1,21 @@
import {Component, inject, OnInit} from '@angular/core'; import {Component, computed, inject, OnInit, signal} from '@angular/core';
import {RouterOutlet} from '@angular/router'; import {isActive, IsActiveMatchOptions, Router, RouterOutlet} from '@angular/router';
import {TuiSegmented} from '@taiga-ui/kit'; 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 {SessionManager} from '@chatenium/chatenium-sdk/services/sessionManager';
import {LoadStatus, ServiceManager} from '../service-manager'; import {LoadStatus, ServiceManager} from '../service-manager';
import {IndexedDB} from '../storage/indexed-db'; import {IndexedDB} from '../storage/indexed-db';
import {DmList} from './dm-list/dm-list'; import {DmList} from './dm-list/dm-list';
import {JsonPipe} from '@angular/common'; import {JsonPipe} from '@angular/common';
import {WebSocketHandler} from '@chatenium/chatenium-sdk/core/webSocketHandler'; 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';
import {UserSettings} from './user-settings/user-settings';
import {AuthService} from '@chatenium/chatenium-sdk/services/authService';
import {UserService} from '@chatenium/chatenium-sdk/services/userService';
@Component({ @Component({
selector: 'app-chat', selector: 'app-chat',
@@ -20,7 +28,14 @@ import {WebSocketHandler} from '@chatenium/chatenium-sdk/core/webSocketHandler';
DmList, DmList,
JsonPipe, JsonPipe,
TuiAppearance, TuiAppearance,
TuiGroup TuiGroup,
TuiDialog,
TranslatePipe,
TuiTabBarComponent,
TuiTabBarItem,
NetworkList,
PictureList,
UserSettings
], ],
templateUrl: './chat.html', templateUrl: './chat.html',
styleUrl: './chat.scss', styleUrl: './chat.scss',
@@ -28,14 +43,73 @@ import {WebSocketHandler} from '@chatenium/chatenium-sdk/core/webSocketHandler';
export class Chat implements OnInit { export class Chat implements OnInit {
serviceManager = inject(ServiceManager) serviceManager = inject(ServiceManager)
indexedDb = inject(IndexedDB) indexedDb = inject(IndexedDB)
breakpoint = inject(TUI_BREAKPOINT)
router = inject(Router)
routerOutletActive = signal(false)
userSettingsOpen = 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: true,
},
{
text: "chat.tabBar.tab4",
icon: '@tui.cog',
implemented: true,
}
];
changeLogOpen = signal(false)
async ngOnInit() { 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 () => { this.indexedDb.openDatabase().then(async () => {
const session = await this.serviceManager.sessionManager.loadPreferredSession() const session = await this.serviceManager.sessionManager.loadPreferredSession()
this.serviceManager.currentSession.set(session) this.serviceManager.currentSession.set(session)
this.serviceManager.currentSessionHandler = new UserService(session.userData.userid, session.token, this.indexedDb.getApi())
await WebSocketHandler.getInstance().connect(session.userData.userid, session.token) await WebSocketHandler.getInstance().connect(session.userData.userid, session.token)
try {
console.log("Updating sessions...")
const sessions = await this.serviceManager.sessionManager.loadSessions()
console.log("Updating sessions: saved sessions loaded...")
await this.serviceManager.sessionManager.updateSessions(sessions)
const session = await this.serviceManager.sessionManager.loadPreferredSession()
console.log("Updating sessions succeeded: updating sessions...")
this.serviceManager.currentSession.set(session)
} catch (e) {
console.warn("Session update failed, skipping...", e)
}
}) })
setTimeout(() => {
const latestRead = localStorage.getItem("changeLogLastRead")
if (latestRead != environment.version) {
this.changeLogOpen.set(true)
}
}, 50)
} }
protected readonly LoadStatus = LoadStatus; protected readonly LoadStatus = LoadStatus;
protected readonly localStorage = localStorage;
protected readonly environment = environment;
} }

View File

@@ -1,9 +1,16 @@
<button tuiButton appearance="secondary" iconStart="@tui.mail-plus"> <button disabled tuiButton appearance="secondary" iconStart="@tui.mail-plus">
{{ "chat.chatnav.dmList.newChat"|translate }} {{ "chat.chatnav.dmList.newChat"|translate }}
</button> </button>
@if (serviceManager.chatsStatus() == LoadStatus.updating) {
<div id="loader">
<tui-loader/>
{{ "updating"|translate }}
</div>
}
@for (chat of serviceManager.chats(); track chat.chatid) { @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> <oimg [src]="chat.pfp" height="35px" width="35px" [radius]="10"></oimg>
<div class="info"> <div class="info">
@if (chat.displayName == "") { @if (chat.displayName == "") {

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import {Component, inject, input, OnInit, signal} from '@angular/core'; import {Component, inject, input, OnInit} from '@angular/core';
import {ChatService} from '@chatenium/chatenium-sdk/services/chatService'; import {ChatService} from '@chatenium/chatenium-sdk/services/chatService';
import {IndexedDB} from '../../storage/indexed-db'; import {IndexedDB} from '../../storage/indexed-db';
import {Chat} from '@chatenium/chatenium-sdk/domain/chatService.schema'; import {TUI_BREAKPOINT, TuiButton, TuiLoader} from '@taiga-ui/core';
import {TuiButton} from '@taiga-ui/core';
import {Oimg} from '../elements/oimg/oimg'; import {Oimg} from '../elements/oimg/oimg';
import {Router, RouterLink} from '@angular/router'; import {Router, RouterLink} from '@angular/router';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
@@ -14,7 +13,8 @@ import {LoadStatus, ServiceManager} from '../../service-manager';
TuiButton, TuiButton,
Oimg, Oimg,
RouterLink, RouterLink,
TranslatePipe TranslatePipe,
TuiLoader
], ],
templateUrl: './dm-list.html', templateUrl: './dm-list.html',
styleUrl: './dm-list.scss', styleUrl: './dm-list.scss',
@@ -26,9 +26,17 @@ export class DmList implements OnInit {
indexedDb = inject(IndexedDB) indexedDb = inject(IndexedDB)
router = inject(Router) router = inject(Router)
serviceManager = inject(ServiceManager) serviceManager = inject(ServiceManager)
breakpoint = inject(TUI_BREAKPOINT)
async ngOnInit() { async ngOnInit() {
this.serviceManager.chatService = new ChatService(this.userid(), this.token(), this.indexedDb.getApi(), () => {}) this.serviceManager.chatService = new ChatService(this.userid(), this.token(), this.indexedDb.getApi(), () => {})
if (this.serviceManager.chatsStatus() != LoadStatus.loaded) {
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 { try {
this.serviceManager.chats.set(await this.serviceManager.chatService.get()) this.serviceManager.chats.set(await this.serviceManager.chatService.get())
this.serviceManager.chatsStatus.set(LoadStatus.loaded) this.serviceManager.chatsStatus.set(LoadStatus.loaded)
@@ -38,3 +46,6 @@ export class DmList implements OnInit {
} }
} }
} }
protected readonly LoadStatus = LoadStatus;
}

View File

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

View File

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

View File

@@ -1,19 +1,20 @@
import {Component, inject, OnInit, signal} from '@angular/core'; import {Component, inject, OnInit, signal} from '@angular/core';
import {DmStorage, ServiceManager} from '../../service-manager'; import {DmStorage, LoadStatus, ServiceManager} from '../../service-manager';
import {ActivatedRoute} from '@angular/router'; import {ActivatedRoute} from '@angular/router';
import {DMService} from '@chatenium/chatenium-sdk/services/dmService'; import {DMService} from '@chatenium/chatenium-sdk/services/dmService';
import {IndexedDB} from '../../storage/indexed-db'; import {IndexedDB} from '../../storage/indexed-db';
import {Navbar} from '../elements/navbar/navbar'; import {Navbar} from '../elements/navbar/navbar';
import {Oimg} from '../elements/oimg/oimg'; 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 {FileDataWithPreview, MessageBox} from '../elements/message-box/message-box';
import {Messages} from '../elements/messages/messages'; import {Messages} from '../elements/messages/messages';
import {Chat} from '@chatenium/chatenium-sdk/domain/chatService.schema'; import {Chat} from '@chatenium/chatenium-sdk/domain/chatService.schema';
import {Message} from '@chatenium/chatenium-sdk/domain/dmService.schema'; import {Message} from '@chatenium/chatenium-sdk/domain/dmService.schema';
import {MessageBoxViewModel} from '../elements/message-box/message-box-viewmodel'; import {MessageBoxViewModel} from '../elements/message-box/message-box-viewmodel';
import {WebSocketHandler} from '@chatenium/chatenium-sdk/core/webSocketHandler'; import {FileUploadProgressListener} from '@chatenium/chatenium-sdk/domain/fileUploadService.schema';
import {FileData, FileUploadProgressListener} from '@chatenium/chatenium-sdk/domain/fileUploadService.schema';
import {Attachment} from '@chatenium/chatenium-sdk/domain/common.schema'; import {Attachment} from '@chatenium/chatenium-sdk/domain/common.schema';
import {MessagesViewModel} from '../elements/messages/messages-viewmodel';
import {v4 as uuidv4} from 'uuid';
@Component({ @Component({
selector: 'app-dm', selector: 'app-dm',
@@ -32,6 +33,7 @@ export class Dm implements OnInit {
serviceManager = inject(ServiceManager) serviceManager = inject(ServiceManager)
route = inject(ActivatedRoute) route = inject(ActivatedRoute)
indexedDb = inject(IndexedDB) indexedDb = inject(IndexedDB)
breakpoint = inject(TUI_BREAKPOINT)
chatid = "" chatid = ""
@@ -40,12 +42,37 @@ export class Dm implements OnInit {
} }
async sendMessage(message: string, files: FileDataWithPreview[] | null) { async sendMessage(message: string, files: FileDataWithPreview[] | null) {
if (!files && message.trim() == "") return
this.scrollToBottom("smooth")
const session = this.serviceManager.currentSession(); const session = this.serviceManager.currentSession();
if (session != null) { 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[] = [] let attachments: Attachment[] = []
files?.forEach(file => { files?.forEach(file => {
const extraMetaData: Record<string, string> = {} const extraMetaData: Record<string, any> = {}
extraMetaData["thumbnailMetaData"] = file.videoThumbnail ?? "" extraMetaData["thumbnailMetaData"] = file.videoThumbnail ?? ""
extraMetaData["progressShown"] = true
extraMetaData["totalChunks"] = 0
extraMetaData["uploadedChunks"] = 0
attachments.push({ attachments.push({
fileName: file.name, fileName: file.name,
@@ -59,6 +86,7 @@ export class Dm implements OnInit {
}) })
}) })
const tempMsgId = uuidv4()
this.store.messages.update(value => [...value, { this.store.messages.update(value => [...value, {
message: message, message: message,
chatid: this.chatid, chatid: this.chatid,
@@ -66,7 +94,7 @@ export class Dm implements OnInit {
replyTo: "", replyTo: "",
author: session.userData.userid, author: session.userData.userid,
seen: false, seen: false,
msgid: "", msgid: tempMsgId,
forwardedFrom: "", forwardedFrom: "",
isEdited: false, isEdited: false,
sent_at: {T: 0, I: 0}, sent_at: {T: 0, I: 0},
@@ -74,16 +102,89 @@ export class Dm implements OnInit {
forwardedFromName: "" forwardedFromName: ""
}]) }])
await this.store.service.sendMessage("", message, null, null, files, <FileUploadProgressListener>{ this.scrollToBottom("smooth")
const respMessage = await this.store.service.sendMessage(tempMsgId, message, null, null, files, <FileUploadProgressListener>{
fileProgressUpdate: (tempMsgId, fileId, allChunks, chunksDone) => { fileProgressUpdate: (tempMsgId, fileId, allChunks, chunksDone) => {
this.uploadProgressUpdate(tempMsgId, fileId, allChunks, chunksDone) this.uploadProgressUpdate(tempMsgId, fileId, allChunks, chunksDone)
} }
}) })
this.updateTempMessage(tempMsgId, respMessage)
} }
} }
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: this.store.messagesVm.scrollBarStatus() == -1 ? scrollContainer.scrollHeight : this.store.messagesVm.scrollBarStatus(),
left: 0,
behavior: anim
});
if (this.store.messagesVm.scrollBarStatus() == -1) {
this.store.messagesVm.scrollBarStatus.set(scrollContainer.scrollHeight)
}
}, 0)
}
handleMessagesScroll(e: any) {
this.store.messagesVm.scrollBarStatus.set(e.target.scrollTop)
}
uploadProgressUpdate(tempMsgId: string, fileId: string, allChunks: number, chunksDone: number) { uploadProgressUpdate(tempMsgId: string, fileId: string, allChunks: number, chunksDone: number) {
console.log(fileId, allChunks, chunksDone) console.log(tempMsgId, fileId, allChunks, chunksDone)
this.store.messages.update(messages =>
messages.map(m => {
if (m.msgid !== tempMsgId) return m;
return {
...m,
files: m.files.map(f => {
if (f.fileId !== fileId) return f;
return {
...f,
extraMetaData: {
...f.extraMetaData,
totalChunks: allChunks,
uploadedChunks: chunksDone
}
};
})
};
})
);
}
updateTempMessage(tempMsgId: string, message: Message) {
this.store.messages.update(messages =>
messages.map(m => {
if (m.msgid !== tempMsgId) return m;
return {
...m,
msgid: message.msgid,
sent_at: message.sent_at,
files: m.files.map(f => {
f.extraMetaData["progressShown"] = false
return f
})
};
})
);
} }
// The chatid parameter ensures isolation // The chatid parameter ensures isolation
@@ -95,6 +196,7 @@ export class Dm implements OnInit {
switch (action) { switch (action) {
case "newMessage": case "newMessage":
targetStore.messages.update(messages => [...messages, data]); targetStore.messages.update(messages => [...messages, data]);
this.scrollToBottom("smooth")
break; break;
} }
} }
@@ -105,20 +207,22 @@ export class Dm implements OnInit {
this.route.params.subscribe(async params => { this.route.params.subscribe(async params => {
const chatid = params['chatid']; const chatid = params['chatid'];
this.chatid = chatid; this.chatid = chatid;
console.log(`Loading chat ${chatid}...`)
const session = this.serviceManager.currentSession(); const session = this.serviceManager.currentSession();
const chatData = this.serviceManager.chats().find(c => c.chatid === chatid); const chatData = this.serviceManager.chats().find(c => c.chatid === chatid);
if (!session || !chatData) { if (!session || !chatData) {
console.warn(`Initialization deferred for ${chatid}: Session or ChatData missing.`); return
return;
} }
if (!this.serviceManager.dmServices()[chatid]) { if (!this.serviceManager.dmServices()[chatid]) {
const newStore = { const newStore = {
chatData: signal<Chat>(chatData), chatData: signal<Chat>(chatData),
messages: signal<Message[]>([]), messages: signal<Message[]>([]),
messagesStatus: signal<LoadStatus>(LoadStatus.loading),
messageBox: new MessageBoxViewModel((msg, files) => this.sendMessage(msg, files)), messageBox: new MessageBoxViewModel((msg, files) => this.sendMessage(msg, files)),
messagesVm: new MessagesViewModel(),
wsListener: (action, data) => this.onWsListen(action, data, chatid), wsListener: (action, data) => this.onWsListen(action, data, chatid),
} as DmStorage; } as DmStorage;
@@ -133,11 +237,25 @@ export class Dm implements OnInit {
this.serviceManager.dmServices()[chatid] = newStore; this.serviceManager.dmServices()[chatid] = newStore;
const currentStore = this.serviceManager.dmServices()[chatid]; const currentStore = this.serviceManager.dmServices()[chatid];
const history = await currentStore.service.get(); try {
currentStore.messages.set(history); const messagesCache = await currentStore.service.getQuick();
currentStore.messages.set(messagesCache);
this.store.messagesStatus.set(LoadStatus.updating)
this.scrollToBottom("instant")
} catch (e) {
console.warn(`Cache load failed: ${e}. Skipping cache load...`)
}
const messages = await currentStore.service.get();
currentStore.messages.set(messages);
this.store.messagesStatus.set(LoadStatus.loaded)
this.scrollToBottom("instant")
await currentStore.service.joinWebSocketRoom(); await currentStore.service.joinWebSocketRoom();
} else {
this.scrollToBottom("instant")
} }
}); });
} }
protected readonly LoadStatus = LoadStatus;
} }

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
</div> </div>
<span>{{ "chat.elements.messageBox.uplDrag.upload"|translate }}</span> <span>{{ "chat.elements.messageBox.uplDrag.upload"|translate }}</span>
</div> </div>
<div class="method"> <div class="method" style="opacity: 0.5">
<div class="icon-holder"> <div class="icon-holder">
<tui-icon icon="@tui.cloud-sync"/> <tui-icon icon="@tui.cloud-sync"/>
</div> </div>
@@ -14,13 +14,25 @@
</div> </div>
<ng-template [(tuiDialog)]="viewModel().dialogOpen" [tuiDialogOptions]="{label: 'chat.elements.messageBox.fileUploadDialog.label'|translate}"> <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) { @for (file of viewModel().files(); track file) {
@if (file.type == "image") { @if (file.type == "image") {
<img [src]="file.blob" style="width: 100%; height: 100%; object-fit: fill;"/> <img [src]="file.blob" style="width: 100%; height: 100%; object-fit: fill;"/>
} }
} }
</masonry> </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%"> <div style="margin-top: 10px; display: flex; gap: 10px; width: 100%">
<tui-textfield style="width: 100%"> <tui-textfield style="width: 100%">
@@ -37,25 +49,45 @@
</div> </div>
</ng-template> </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"> <div class="items-left">
<button tuiButton appearance="flat" (click)="uplInput.click()"> <button tuiButton appearance="flat" (click)="uplInput.click()">
<tui-icon icon="@tui.file-up"/> <tui-icon icon="@tui.file-up"/>
<input #uplInput type="file" (change)="handleFileInput($event)" multiple hidden/> <input #uplInput type="file" (change)="handleFileInput($event)" multiple hidden/>
</button> </button>
<button tuiButton appearance="flat"> <button tuiButton appearance="flat" disabled>
<tui-icon icon="@tui.cloud-sync"/> <tui-icon icon="@tui.cloud-sync"/>
</button> </button>
</div> </div>
<div class="items-middle"> <div class="items-middle">
<textarea [style]="'height:'+textareaHeight+'px;'" #message (input)="onTextAreaInput(message)" [(ngModel)]="viewModel().message"></textarea> <textarea (keydown.enter)="viewModel().onMessageSend(message.value, null); viewModel().message.set(''); viewModel().editingMessage.set(null); handleEnterKeydown($event)" [style]="'height:'+textareaHeight+'px;'" #message (input)="onTextAreaInput(message)" [(ngModel)]="viewModel().message"></textarea>
<span class="placeholder" <span class="placeholder"
[class.hidden]="message.value != ''">{{ "chat.elements.messageBox.placeholder"|translate }}</span> [class.hidden]="message.value != ''">{{ "chat.elements.messageBox.placeholder"|translate }}</span>
</div> </div>
<div class="items-right"> <div class="items-right">
<button tuiButton appearance="flat" (click)="viewModel().onMessageSend(message.value, null)"> <button [disabled]="message.value.trim() == ''" tuiButton appearance="flat" (click)="viewModel().onMessageSend(message.value, null); viewModel().message.set(''); viewModel().editingMessage.set(null);">
<tui-icon icon="@tui.send"/> <tui-icon icon="@tui.send"/>
</button> </button>
</div> </div>
</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 { #message-box {
z-index: 5;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
width: 60%; width: 60%;
background: var(--tui-background-base-alt); background: var(--tui-background-base-alt);
@@ -60,6 +106,10 @@
align-items: center; align-items: center;
padding: 0 10px; padding: 0 10px;
&.fullWidth {
width: 100%;
}
.items-left, .items-middle, .items-right { .items-left, .items-middle, .items-right {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -117,3 +167,4 @@
} }
} }
} }
}

View File

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

View File

@@ -0,0 +1,6 @@
import {signal} from '@angular/core';
export class MessagesViewModel {
// Saves scrolling state. First value initialized when scrolling to bottom on the message load
scrollBarStatus = signal<number>(-1)
}

View File

@@ -1,8 +1,25 @@
@if (loading()) {
@for (_ of [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}]; track _; let i = $index) {
<div class="message"
[class.chained_start]="i == 0 || i == 3 || i == 6"
[class.chained_end]="i == 2 || i == 5 || i == 9"
[class.chained_middle]="i != 0 && i != 3 && i != 6 && i != 2 && i != 5 && i != 9"
[class.author]="i == 3 || i == 4 || i == 5"
>
<div class="bubble" [tuiSkeleton]="true">
{{i}}
</div>
</div>
}
} @else {
@if (serviceManager.currentSession != null) { @if (serviceManager.currentSession != null) {
@for (message of messages(); track message.msgid; let i = $index) { @for (message of messages(); track message.msgid; let i = $index) {
<div <div
class="message" 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_start]="isMessageStartOfChain(i)"
[class.chained_middle]="isMessageMiddleInChain(i)" [class.chained_middle]="isMessageMiddleInChain(i)"
[class.chained_end]="isMessageEndOfChain(i)" [class.chained_end]="isMessageEndOfChain(i)"
@@ -10,23 +27,82 @@
<div class="above"> <div class="above">
<span>{{ message.sent_at.T * 1000 | date: 'HH:mm' }}</span> <span>{{ message.sent_at.T * 1000 | date: 'HH:mm' }}</span>
</div> </div>
<div class="bubble"> <div class="bubble" tuiDropdownContext [tuiDropdown]="messageContextMenu">
<span class="message-text">{{ message.message }}</span> <span class="message-text">{{ message.message }}</span>
<masonry style="max-height: 300px"> <masonry style="max-height: 300px">
@for (file of message.files; track file) { @for (file of filterExpressedMedia(message.files); track file) {
<div style="position:relative;">
@if (file.extraMetaData && Object.keys(file.extraMetaData).length > 0) {
@if (file.extraMetaData['progressShown']) {
<tui-progress-circle
style="position: absolute; top: 0; right: 0; z-index: 10; transform: translate(-50%, -50%)"
[max]="file.extraMetaData['totalChunks']"
[value]="file.extraMetaData['uploadedChunks']"
/>
}
}
@if (file.type == "image") { @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") { } @else if (file.type == "video") {
@if (file.extraMetaData && Object.keys(file.extraMetaData).length > 0) { @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 { } @else {
<video-player maxHeight="250px" maxWidth="250px" [src]="file.path"></video-player> <video-player maxHeight="300px" maxWidth="300px" [src]="file.path"></video-player>
} }
} }
</div>
} }
</masonry> </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-base-alt); gap: 5px">
@if (file.extraMetaData && Object.keys(file.extraMetaData).length > 0) {
@if (file.extraMetaData['progressShown']) {
<tui-progress-circle
size="xs"
[max]="file.extraMetaData['totalChunks']"
[value]="file.extraMetaData['uploadedChunks']"
/>
} @else {
<tui-icon icon="@tui.file"/>
}
} @else {
<tui-icon icon="@tui.file"/>
}
<span>{{ file.fileName }}</span>
</div>
}
</div>
</div> </div>
<div class="below"></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> </div>
} }
} }
}

View File

@@ -4,10 +4,12 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden;
.message { .message {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: start;
&.author { &.author {
align-items: end; align-items: end;
@@ -15,6 +17,24 @@
.bubble { .bubble {
background: var(--tui-background-accent-1-hover); background: var(--tui-background-accent-1-hover);
} }
&.chained_start {
.bubble {
border-radius: 10px 25px 10px 10px !important;
}
}
&.chained_middle {
.bubble {
border-radius: 10px !important;
}
}
&.chained_end {
.bubble {
border-radius: 10px 10px 25px 10px !important;
}
}
} }
&.chained_start { &.chained_start {
@@ -56,13 +76,14 @@
max-width: 50%; max-width: 50%;
min-width: 250px; min-width: 250px;
min-height: 40px; min-height: 40px;
max-height: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 10px; padding: 10px;
.message-text { .message-text {
white-space: none; white-space: break-spaces;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
} }

View File

@@ -1,17 +1,41 @@
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} from '@chatenium/chatenium-sdk/domain/dmService.schema';
import {Message as NetworkMessage} from '@chatenium/chatenium-sdk/domain/textChannelService.schema' import {Message as NetworkMessage} from '@chatenium/chatenium-sdk/domain/textChannelService.schema'
import {ServiceManager} from '../../../service-manager'; import {ServiceManager} from '../../../service-manager';
import {DatePipe} from '@angular/common'; import {DatePipe} from '@angular/common';
import {Masonry} from '../masonry/masonry'; import {Masonry} from '../masonry/masonry';
import {VideoPlayer} from '../video-player/video-player'; 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';
import {TuiProgressCircle, TuiSkeleton} from '@taiga-ui/kit';
@Component({ @Component({
selector: 'messages', selector: 'messages',
imports: [ imports: [
DatePipe, DatePipe,
Masonry, Masonry,
VideoPlayer VideoPlayer,
TuiDropdownContext,
TuiDropdown,
TuiButton,
TuiOption,
TranslatePipe,
TuiDataListComponent,
TuiGroup,
TuiIcon,
TuiProgressCircle,
TuiSkeleton
], ],
templateUrl: './messages.html', templateUrl: './messages.html',
styleUrl: './messages.scss', styleUrl: './messages.scss',
@@ -19,7 +43,11 @@ import {VideoPlayer} from '../video-player/video-player';
export class Messages { export class Messages {
serviceManager = inject(ServiceManager) serviceManager = inject(ServiceManager)
messages = input<Message[] | NetworkMessage[]>([]) messages = input.required<Message[] | NetworkMessage[]>()
messageBoxViewModel = input.required<MessageBoxViewModel>()
loading = input(false)
@Output() onDelete = new EventEmitter<string>()
/** /**
* Helps code readability by specifying what type of messages are being processed. * Helps code readability by specifying what type of messages are being processed.
@@ -111,5 +139,13 @@ export class Messages {
return i + 1 === this.messages().length; 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; 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> <ng-content></ng-content>
</nav> </nav>

View File

@@ -2,7 +2,31 @@ nav {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; 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; display: flex;
gap: 10px; gap: 10px;
align-items: center; 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({ @Component({
selector: 'navbar', selector: 'navbar',
imports: [], imports: [
TuiAppBarComponent,
TuiPlatform,
NgTemplateOutlet,
TuiButton,
TranslatePipe
],
templateUrl: './navbar.html', templateUrl: './navbar.html',
styleUrl: './navbar.scss', 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("") width = input("")
src = input("") src = input("")
radius = input(15) radius = input(15)
objectFit = input("cover")
} }

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import {Component, Input, ViewEncapsulation} from '@angular/core'; import {Component, Input, ViewEncapsulation} from '@angular/core';
import { import {
TuiButton, TuiButton,
TuiDropdownDirective, TuiDropdown,
TuiDropdownOpen, TuiDropdownOpen,
TuiDropdownOptionsDirective, TuiDropdownOptionsDirective,
TuiIcon, TuiIcon,
@@ -21,7 +21,7 @@ import {TuiInputDateRange, TuiProgressBar} from '@taiga-ui/kit';
TuiSlider, TuiSlider,
TuiProgressBar, TuiProgressBar,
TuiDropdownOptionsDirective, TuiDropdownOptionsDirective,
TuiDropdownDirective, TuiDropdown,
TuiDropdownOpen, TuiDropdownOpen,
TuiInputDateRange 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,51 @@
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 {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(), () => {})
if (this.serviceManager.networksStatus() != LoadStatus.loaded) {
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 [loading]="store!.messagesStatus() == LoadStatus.loading" (scrollend)="handleMessagesScroll($event)" [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,303 @@
import {Component, inject, signal} from '@angular/core';
import {DmStorage, LoadStatus, 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';
import {MessagesViewModel} from '../../../elements/messages/messages-viewmodel';
import {v4 as uuidv4} from 'uuid';
@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
}
}
return
}
let attachments: Attachment[] = []
files?.forEach(file => {
const extraMetaData: Record<string, any> = {}
extraMetaData["thumbnailMetaData"] = file.videoThumbnail ?? ""
extraMetaData["progressShown"] = true
extraMetaData["totalChunks"] = 0
extraMetaData["uploadedChunks"] = 0
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
})
})
const tempMsgId = uuidv4()
this.store.messages.update(value => [...value, {
author: {
userid: session.userData.userid,
pfp: session.userData.pfp,
username: session.userData.username,
displayName: session.userData.displayName
},
msgid: tempMsgId,
message: message,
sent_at: {
T: 0,
I: 0
},
isEdited: false,
channelId: "",
networkId: "",
categoryId: "",
files: attachments,
seen: false,
replyTo: "",
replyToId: "",
forwardedFrom: "",
forwardedFromName: ""
}])
this.scrollToBottom("smooth")
const respMessage = await this.store.service.sendMessage(tempMsgId, message, null, null, files, <FileUploadProgressListener>{
fileProgressUpdate: (tempMsgId, fileId, allChunks, chunksDone) => {
this.uploadProgressUpdate(tempMsgId, fileId, allChunks, chunksDone)
}
})
this.updateTempMessage(tempMsgId, respMessage)
}
}
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(() => {
if (!this.store) {
return
}
const scrollContainer = <HTMLDivElement>document.querySelector("#scrollContainer")
scrollContainer.scroll({
top: this.store.messagesVm.scrollBarStatus() == -1 ? scrollContainer.scrollHeight : this.store.messagesVm.scrollBarStatus(),
left: 0,
behavior: anim
});
if (this.store.messagesVm.scrollBarStatus() == -1) {
this.store.messagesVm.scrollBarStatus.set(scrollContainer.scrollHeight)
}
}, 0)
}
handleMessagesScroll(e: any) {
if (!this.store) return
this.store.messagesVm.scrollBarStatus.set(e.target.scrollTop)
}
uploadProgressUpdate(tempMsgId: string, fileId: string, allChunks: number, chunksDone: number) {
if (!this.store) return
this.store.messages.update(messages =>
messages.map(m => {
if (m.msgid !== tempMsgId) return m;
return {
...m,
files: m.files.map(f => {
if (f.fileId !== fileId) return f;
return {
...f,
extraMetaData: {
...f.extraMetaData,
totalChunks: allChunks,
uploadedChunks: chunksDone
}
};
})
};
})
);
}
updateTempMessage(tempMsgId: string, message: Message) {
if (!this.store) return
this.store.messages.update(messages =>
messages.map(m => {
if (m.msgid !== tempMsgId) return m;
return {
...m,
msgid: message.msgid,
sent_at: message.sent_at,
files: m.files.map(f => {
f.extraMetaData["progressShown"] = false
return f
})
};
})
);
}
// 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[]>([]),
messagesVm: new MessagesViewModel(),
messagesStatus: signal<LoadStatus>(LoadStatus.loading),
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);
this.scrollToBottom("instant")
currentStore.messagesStatus.set(LoadStatus.updating)
} catch (e) {
console.warn(`Cache load failed: ${e}. Skipping cache load...`)
}
const messages = await currentStore.service.get();
currentStore.messages.set(messages);
currentStore.messagesStatus.set(LoadStatus.loaded)
this.scrollToBottom("instant")
await currentStore.service.joinWebSocketRoom();
} else {
this.scrollToBottom("instant")
}
});
}
protected readonly LoadStatus = LoadStatus;
}

View File

@@ -0,0 +1,82 @@
@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>
<tui-tabs [(activeItemIndex)]="tabActiveIndex">
<button
iconStart="@tui.hash"
tuiTab
type="button"
>
{{"chat.network.tabs.channels"|translate}}
</button>
<button
disabled
iconStart="@tui.users"
tuiTab
type="button"
>
{{"chat.network.tabs.members"|translate}}
</button>
<button
iconStart="@tui.cog"
tuiTab
type="button"
>
{{"chat.network.tabs.settings"|translate}}
</button>
</tui-tabs>
<br/>
@switch (tabActiveIndex) {
@case (0) {
<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>
}
@case (2) {
<main id="settings">
<network-settings [networkStore]="this.store"></network-settings>
</main>
}
}
</div>
</div>
}

View File

@@ -0,0 +1,57 @@
#layout {
overflow-y: scroll;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 0 100%;
overflow-x: hidden;
padding: 15px;
transition: 0.2s;
#network-data {
display: flex;
flex-direction: column;
}
&.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;
}
}
#settings {
width: 100%;
height: 100%;
background: var(--tui-background-base-alt);
border-radius: 20px;
}
}

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,89 @@
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';
import {TuiTab, TuiTabsHorizontal, TuiTabsWithMore} from '@taiga-ui/kit';
import {TuiItem} from '@taiga-ui/cdk';
import {TranslatePipe} from '@ngx-translate/core';
import {Settings} from './settings/settings';
@Component({
selector: 'app-network',
imports: [
RouterOutlet,
Navbar,
Oimg,
TuiGroup,
TuiButton,
TuiIcon,
RouterLink,
TuiTabsWithMore,
TuiItem,
TuiTab,
TranslatePipe,
TuiTabsHorizontal,
Settings
],
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)
tabActiveIndex = 2
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,114 @@
<div tuiGroup id="options" orientation="vertical">
<div>
<header>
<tui-icon icon="@tui.image"></tui-icon>
{{ "chat.network.settings.overviewPage.networkPicture"|translate }}
<oimg [src]="networkStore().networkData().picture" height="25px" width="25px" [radius]="6"/>
</header>
<main>
<button tuiButton appearance="outline" disabled iconEnd="@tui.file-up">
{{ "chat.network.settings.overviewPage.uploadNewPicture"|translate }}
</button>
</main>
</div>
<div>
<ng-template [(tuiDialog)]="setNewNameDialogOpen"
[tuiDialogOptions]="{label: 'chat.network.settings.overviewPage.setNewName'|translate}">
<tui-textfield #newName iconStart="@tui.signature">
<label tuiLabel>{{"chat.network.settings.overviewPage.setNewNameDialog.label"|translate}}</label>
<input type="text" tuiInput>
</tui-textfield>
@if (setNewNameError() != "") {
<tui-error [error]="setNewNameError()"></tui-error>
}
<footer>
<button tuiButton iconStart="@tui.check" [loading]="setNewNamePending()" [disabled]="setNewNamePending()" (click)="setNewName(newName.value())">
{{'chat.network.settings.overviewPage.setNewName'|translate}}
</button>
</footer>
</ng-template>
<header>
<tui-icon icon="@tui.network"></tui-icon>
{{ "chat.network.settings.overviewPage.networkName"|translate }}
<b style="color: gray">{{ networkStore().networkData().name }}</b>
</header>
<main>
<button (click)="setNewNameDialogOpen.set(true)" tuiButton appearance="outline" iconEnd="@tui.pencil">
{{ "chat.network.settings.overviewPage.setNewName"|translate }}
</button>
</main>
</div>
<div>
<ng-template [(tuiDialog)]="makeNetworkPrivateWarnDialogOpen"
[tuiDialogOptions]="{label: 'chat.network.settings.overviewPage.changeToPrivateDialog.label'|translate}">
<ul>
<li>
<tui-icon icon="@tui.eye-off"/>
{{ "chat.network.settings.overviewPage.changeToPrivateDialog.warn.1"|translate }}
</li>
<li>
<tui-icon icon="@tui.ticket"/>
{{ "chat.network.settings.overviewPage.changeToPrivateDialog.warn.2"|translate }}
</li>
<li>
<tui-icon icon="@tui.radio"/>
{{ "chat.network.settings.overviewPage.changeToPrivateDialog.warn.3"|translate }}
</li>
</ul>
<footer>
<button tuiButton iconStart="@tui.eye-off" [loading]="networkVisChangePending()" [disabled]="networkVisChangePending()" (click)="changeVisibility('private')">
{{'chat.network.settings.overviewPage.changeToPrivate'|translate}}
</button>
</footer>
</ng-template>
<ng-template [(tuiDialog)]="makeNetworkPublicWarnDialogOpen"
[tuiDialogOptions]="{label: 'chat.network.settings.overviewPage.changeToPublicDialog.label'|translate}">
<ul>
<li>
<tui-icon icon="@tui.eye"/>
{{ "chat.network.settings.overviewPage.changeToPublicDialog.warn.1"|translate }}
</li>
<li>
<tui-icon icon="@tui.ticket"/>
{{ "chat.network.settings.overviewPage.changeToPublicDialog.warn.2"|translate }}
</li>
<li>
<tui-icon icon="@tui.radio"/>
{{ "chat.network.settings.overviewPage.changeToPublicDialog.warn.3"|translate }}
</li>
</ul>
<footer>
<button tuiButton iconStart="@tui.eye" [loading]="networkVisChangePending()" [disabled]="networkVisChangePending()" (click)="changeVisibility('public')">
{{'chat.network.settings.overviewPage.changeToPublic'|translate}}
</button>
</footer>
</ng-template>
<header>
@if (networkStore().networkData().visibility == "public") {
<tui-icon icon="@tui.eye"></tui-icon>
} @else {
<tui-icon icon="@tui.eye-off"></tui-icon>
}
{{ "chat.network.settings.overviewPage.networkVisibility"|translate }}
</header>
<main>
@if (networkStore().networkData().visibility == "public") {
<button tuiButton appearance="outline" iconEnd="@tui.eye-off" (click)="makeNetworkPrivateWarnDialogOpen.set(true)">
{{ "chat.network.settings.overviewPage.changeToPrivate"|translate }}
</button>
} @else {
<button tuiButton appearance="outline" iconEnd="@tui.eye" (click)="makeNetworkPublicWarnDialogOpen.set(true)">
{{ "chat.network.settings.overviewPage.changeToPublic"|translate }}
</button>
}
</main>
</div>
</div>

View File

@@ -0,0 +1,31 @@
#options {
display: flex;
flex-direction: column;
div {
width: 100%;
height: 60px;
background: var(--tui-background-base-alt);
padding: 15px;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 10px;
header {
display: flex;
gap: 5px;
align-items: center;
}
main {
display: flex;
align-items: center;
justify-content: end;
button {
height: 35px;
}
}
}
}

View File

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

View File

@@ -0,0 +1,79 @@
import {Component, inject, input, signal} from '@angular/core';
import {NetworkStorage} from '../../../../service-manager';
import {
TuiAlertService,
TuiButton,
TuiDialog,
TuiErrorComponent,
TuiGroup,
TuiIcon,
TuiInputDirective
} from '@taiga-ui/core';
import {TranslatePipe} from '@ngx-translate/core';
import {Oimg} from '../../../elements/oimg/oimg';
import {environment} from '../../../../../environments/environment';
import {TuiButtonLoading, TuiComboBox} from '@taiga-ui/kit';
@Component({
selector: 'network-settings-overview',
imports: [
TuiGroup,
TranslatePipe,
TuiIcon,
Oimg,
TuiButton,
TuiDialog,
TuiComboBox,
TuiInputDirective,
TuiButtonLoading,
TuiErrorComponent
],
templateUrl: './overview.html',
styleUrl: './overview.scss',
})
export class Overview {
networkStore = input.required<NetworkStorage>()
protected readonly environment = environment;
protected readonly localStorage = localStorage;
setNewNameDialogOpen = signal(false)
setNewNamePending = signal(false)
setNewNameError = signal("")
makeNetworkPrivateWarnDialogOpen = signal(false)
makeNetworkPublicWarnDialogOpen = signal(false)
networkVisChangePending = signal(false)
async setNewName(name: string) {
this.setNewNameError.set("")
this.setNewNamePending.set(true)
try {
await this.networkStore().service.editName(name)
this.setNewNamePending.set(false)
this.setNewNameDialogOpen.set(false)
this.networkStore().networkData.update(
value => {
value.name = name
return value
}
)
} catch (e) {
this.setNewNamePending.set(false)
this.setNewNameError.set(e as string)
}
}
async changeVisibility(newVisibility: "public" | "private") {
this.networkVisChangePending.set(true)
await this.networkStore().service.changeVisibility(newVisibility)
this.networkStore().networkData.update(
value => {
value.visibility = newVisibility
return value
}
)
this.networkVisChangePending.set(false)
this.makeNetworkPrivateWarnDialogOpen.set(false)
this.makeNetworkPublicWarnDialogOpen.set(false)
}
}

View File

@@ -0,0 +1,29 @@
<aside>
@for (category of networkSettingsOptions; track category) {
<header>
<tui-icon [icon]="'@tui.'+category.icon"></tui-icon>
@if (category.name == "networkName") {
{{networkStore().networkData().name}}
} @else {
{{'chat.network.settings.options.categories.'+category.name|translate}}
}
</header>
<div class="optionList">
@for (option of category.options; track option;) {
<button (click)="selectedOption = option.name" [appearance]="selectedOption == option.name ? 'primary' : 'flat'" tuiButton [disabled]="!option.implemented || !optionRequiredPermissionsGranted(networkStore().networkData().permissions, option.requiredAtLeastOneOf ?? [])">
<tui-icon [icon]="'@tui.'+option.icon"></tui-icon>
{{'chat.network.settings.options.'+option.name|translate}}
</button>
}
</div>
}
</aside>
<main>
@switch (selectedOption) {
@case ("overview") {
<network-settings-overview [networkStore]="networkStore()"/>
}
}
</main>

View File

@@ -0,0 +1,37 @@
:host {
height: 100%;
width: 100%;
display: grid;
grid-template-columns: 300px 1fr;
aside {
height: 100%;
width: 100%;
border-radius: 20px 0 0 20px;
padding: 15px;
header {
display: flex;
gap: 5px;
align-items: center;
}
div {
display: flex;
flex-direction: column;
gap: 1px;
padding: 5px;
button {
height: 40px;
display: flex;
justify-content: start;
}
}
}
main {
background: var(--tui-background-neutral-2);
border-radius: 0 20px 20px 0;
padding: 15px;
}
}

View File

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

View File

@@ -0,0 +1,150 @@
import {Component, inject, input} from '@angular/core';
import {TuiButton, TuiGroup, TuiIcon} from '@taiga-ui/core';
import {permissionGranted, permissions} from '@chatenium/chatenium-sdk/core/permissions';
import {NetworkStorage, ServiceManager} from '../../../service-manager';
import {Session} from '@chatenium/chatenium-sdk/domain/sessionManager.schema';
import {SessionManager} from '@chatenium/chatenium-sdk/services/sessionManager';
import {TranslatePipe} from '@ngx-translate/core';
import {Overview} from './overview/overview';
@Component({
selector: 'network-settings',
imports: [
TuiGroup,
TuiIcon,
TranslatePipe,
TuiButton,
Overview
],
templateUrl: './settings.html',
styleUrl: './settings.scss',
})
export class Settings {
serviceManager = inject(ServiceManager)
networkStore = input.required<NetworkStorage>()
selectedOption = "overview"
optionRequiredPermissionsGranted(permissions: number, required: number[]) {
if (!this.serviceManager.currentSession) {
return false
}
let granted = false;
if (this.networkStore().networkData().createdBy == this.serviceManager.currentSession()!.userData.userid) {
return true
}
required.forEach((permission) => {
if (permissionGranted(permissions, permission)) {
granted = true
}
})
return granted
}
networkSettingsOptions: {
name: string,
icon: string,
options: {
name: string, // ? channel.settings.[name]
icon: string // ? Class name
requiredAtLeastOneOf?: number[],
implemented: boolean
}[]
}[] = [
{
name: "networkName", // ? UI Will show network name
icon: "network",
options: [
{
icon: "eye",
name: "overview",
implemented: true,
requiredAtLeastOneOf: [permissions.changeNetworkNamePictureAndVisibility]
},
{
name: "rank",
icon: "book",
requiredAtLeastOneOf: [permissions.createAndEditRanks, permissions.deleteRanks],
implemented: false,
},
{
name: "emoji",
icon: "smile",
implemented: false,
requiredAtLeastOneOf: [permissions.createEmojis]
},
{
name: "embed",
icon: "code",
implemented: false,
requiredAtLeastOneOf: [permissions.manageEmbed]
},
{
name: "invite",
icon: "ticket",
implemented: false,
requiredAtLeastOneOf: [permissions.createInvites, permissions.deleteInvites]
}
]
},
{
name: "apps",
icon: "layout-panel-left",
options: [
{
name: "webhook",
icon: "globe",
implemented: false,
requiredAtLeastOneOf: [permissions.deleteWebhooks, permissions.createWebhooks]
},
{
name: "bots",
icon: "bot",
implemented: false,
}
]
},
{
name: "moderation",
icon: "shield-half",
options: [
{
name: "activityHistory",
icon: "scroll-text",
implemented: false,
},
{
name: "bans",
icon: "gavel",
implemented: false,
requiredAtLeastOneOf: [permissions.banMembers]
}
]
},
{
name: "community",
icon: "users",
options: [
{
name: "communityChannels",
icon: "hash",
implemented: false,
},
{
name: "networkIntroducer",
icon: "network",
implemented: false,
},
{
name: "members",
icon: "users",
implemented: false,
}
]
}
]
}

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,49 @@
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(), () => {})
if (this.serviceManager.chatsStatus() != LoadStatus.loaded) {
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,100 @@
<div class="option" [class.mobile]="breakpoint() == 'mobile'">
<ng-template [(tuiDialog)]="changeEmailDialogOpen"
[tuiDialogOptions]="{label: ('chat.userSettingsDialog.security.changeEmailDialog.'+ (changeEmailRemoveMode() ? 'labelRemove' : serviceManager.currentSession()!.userData.emailSet ? 'label' : 'labelSet'))|translate}">
<p>{{ "chat.userSettingsDialog.security.changeEmailDialog.warn"|translate }}</p>
@if (step() == 0) {
<form [formGroup]="changeEmailForm" style="display: flex; flex-direction: column; gap: 10px">
@if (!changeEmailRemoveMode()) {
<tui-textfield iconStart="@tui.mail">
<label
tuiLabel>{{ "chat.userSettingsDialog.security.changeEmailDialog.newMail"|translate }}</label>
<input formControlName="newAddress" tuiInput type="email">
</tui-textfield>
}
<tui-textfield iconStart="@tui.key">
<label tuiLabel>{{ "chat.userSettingsDialog.security.changeEmailDialog.currentPassword"|translate }}</label>
<input formControlName="currentPassword" tuiInput type="password">
</tui-textfield>
@if (changeEmailForm.controls['currentPassword'].dirty) {
@if (changeEmailForm.controls['currentPassword'].hasError("required")) {
<tui-error
[error]="'chat.userSettingsDialog.security.changeEmailDialog.errors.currentPasswordRequired'|translate"></tui-error>
}
@if (changeEmailForm.controls['currentPassword'].hasError("incorrect")) {
<tui-error
[error]="'chat.userSettingsDialog.security.changeEmailDialog.errors.incorrectPassword'|translate"></tui-error>
}
}
</form>
<footer>
<button tuiButton iconStart="@tui.check" [loading]="changeEmailPending()"
[disabled]="changeEmailPending()"
(click)="changeEmail(changeEmailForm.controls['currentPassword'].value, changeEmailForm.controls['newAddress'].value)">
{{ ('chat.userSettingsDialog.security.changeEmailDialog.' + (changeEmailRemoveMode() ? 'labelRemove' : serviceManager.currentSession()!.userData.passwordSet ? 'label' : 'labelSet'))|translate }}
</button>
</footer>
} @else {
<form style="display: flex; flex-direction: column; gap: 10px" [formGroup]="verifyEmailForm">
@if (serviceManager.currentSession()!.userData.emailSet) {
<tui-textfield iconStart="@tui.mail">
<label tuiLabel>{{ "chat.userSettingsDialog.security.changeEmailDialog.oldCode"|translate }}</label>
<input tuiInput type="number" [step]="1" formControlName="oldCode">
</tui-textfield>
}
@if (!changeEmailRemoveMode()) {
<tui-textfield iconStart="@tui.badge-plus">
<label tuiLabel>{{ "chat.userSettingsDialog.security.changeEmailDialog.newCode"|translate }}</label>
<input tuiInput type="number" [step]="1" formControlName="newCode">
</tui-textfield>
}
</form>
<footer>
<button tuiButton iconStart="@tui.check" [loading]="verifyEmailPending()"
[disabled]="verifyEmailForm.invalid || verifyEmailPending()"
(click)="verifyEmail(verifyEmailForm.controls['newCode'].value, verifyEmailForm.controls['oldCode'].value)">
{{ ('chat.userSettingsDialog.security.changeEmailDialog.' + (changeEmailRemoveMode() ? 'labelRemove' : serviceManager.currentSession()!.userData.passwordSet ? 'label' : 'labelSet'))|translate }}
</button>
</footer>
}
</ng-template>
<header>
<tui-icon icon="@tui.mail"/>
<span>{{ "chat.userSettingsDialog.security.email"|translate }}</span>
@if (serviceManager.currentSession()!.userData.emailSet) {
<div tuiBadge appearance="positive"
iconStart="@tui.check">{{ 'chat.userSettingsDialog.security.set'|translate }}
</div>
} @else {
<div tuiBadge appearance="negative"
iconStart="@tui.x">{{ 'chat.userSettingsDialog.security.notSet'|translate }}
</div>
}
</header>
<main>
<button tuiButton appearance="outline" (click)="openChangeEmailDialog(false)"
[iconStart]="serviceManager.currentSession()!.userData.emailSet ? '@tui.pencil' : '@tui.plus'">
@if (serviceManager.currentSession()!.userData.phoneSet) {
{{ "chat.userSettingsDialog.security.changeMail"|translate }}
} @else {
{{ "chat.userSettingsDialog.security.setMail"|translate }}
}
</button>
@if (serviceManager.currentSession()!.userData.emailSet) {
<button tuiButton appearance="outline" tuiAppearanceMode="invalid" iconStart="@tui.x" [disabled]="!serviceManager.currentSession()!.userData.phoneSet && !serviceManager.currentSession()!.userData.phoneSet"
(click)="openChangeEmailDialog(true)"
style="color: var(--tui-text-negative)">
{{ "chat.userSettingsDialog.security.removeMail"|translate }}
</button>
}
</main>
</div>

View File

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

View File

@@ -0,0 +1,112 @@
import {Component, inject, signal} from '@angular/core';
import {TranslatePipe} from '@ngx-translate/core';
import {TuiBadge, TuiButtonLoading, TuiInputNumber} from '@taiga-ui/kit';
import {
TUI_BREAKPOINT,
TuiButton,
TuiDialog,
TuiErrorComponent,
TuiIcon,
TuiInputDirective,
TuiLabel,
TuiTextfieldComponent
} from '@taiga-ui/core';
import {ServiceManager} from '../../../../service-manager';
import {
AbstractControl,
FormControl,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
Validators
} from '@angular/forms';
@Component({
selector: 'user-settings-security-email',
imports: [
TranslatePipe,
TuiBadge,
TuiButton,
TuiIcon,
ReactiveFormsModule,
TuiButtonLoading,
TuiDialog,
TuiErrorComponent,
TuiInputDirective,
TuiLabel,
TuiTextfieldComponent,
TuiInputNumber,
],
templateUrl: './email.html',
styleUrl: './email.scss',
})
export class Email {
serviceManager = inject(ServiceManager)
breakpoint = inject(TUI_BREAKPOINT)
step = signal(0)
changeEmailDialogOpen = signal(false)
changeEmailRemoveMode = signal(false)
changeEmailPending = signal(false)
verifyEmailPending = signal(false)
newAddress = ""
changeEmailForm = new FormGroup({
newAddress: new FormControl(""),
currentPassword: new FormControl(""),
})
verifyEmailForm = new FormGroup({
oldCode: new FormControl(0),
newCode: new FormControl(0, {validators: [Validators.required]})
})
openChangeEmailDialog(modeRemove: boolean) {
this.changeEmailDialogOpen.set(true)
this.changeEmailRemoveMode.set(modeRemove)
this.changeEmailForm.controls["currentPassword"].clearValidators()
this.changeEmailForm.controls["newAddress"].clearValidators()
if (!modeRemove) {
this.changeEmailForm.controls["newAddress"].setValidators([Validators.required])
this.changeEmailForm.controls["newAddress"].updateValueAndValidity()
}
this.changeEmailForm.controls["currentPassword"].setValidators([Validators.required])
this.changeEmailForm.controls["currentPassword"].updateValueAndValidity()
}
async changeEmail(currentPassword: string | null, newMail: string | null) {
if (this.changeEmailRemoveMode()) {
newMail = "remove"
}
this.changeEmailPending.set(true)
const service = this.serviceManager.currentSessionHandler
if (service) {
try {
await service.changeEmail(newMail ?? "", currentPassword ?? "")
this.step.set(1)
this.newAddress = newMail ?? ""
if (!this.changeEmailRemoveMode()) {
this.verifyEmailForm.controls["oldCode"].setValidators([Validators.required])
}
} catch (e) {
this.changeEmailForm.controls["currentPassword"].setErrors({incorrect: true})
this.changeEmailPending.set(false)
}
}
}
async verifyEmail(newCode: number | null, oldCode: number | null) {
this.verifyEmailPending.set(true)
const service = this.serviceManager.currentSessionHandler
if (service) {
try {
await service.verifyEmailChange(oldCode ?? 0, newCode ?? 0, this.newAddress)
this.changeEmailDialogOpen.set(false)
} catch (e) {
this.verifyEmailForm.controls["newCode"].setErrors({incorrect: true})
this.verifyEmailPending.set(false)
}
}
}
}

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