More webapp changes+fixes
All checks were successful
Build Docker and Deploy / Build Docker Container (push) Successful in 1m0s
Build Docker and Deploy / Run Unit-Tests (push) Successful in 9m18s
Build Docker and Deploy / Deploy to Server (push) Successful in 22s

This commit is contained in:
2025-12-07 04:21:11 +01:00
parent 2b7950f5dc
commit c81143ecdc
17 changed files with 297 additions and 20 deletions

View File

@@ -39,6 +39,12 @@ import {
InfoCircleOutline, InfoCircleOutline,
ExclamationCircleOutline, ExclamationCircleOutline,
CheckCircleOutline, CheckCircleOutline,
UserAddOutline,
UserDeleteOutline,
PauseCircleOutline,
PlayCircleOutline,
StopOutline,
ArrowLeftOutline,
} from '@ant-design/icons-angular/icons'; } from '@ant-design/icons-angular/icons';
import { routes } from './app.routes'; import { routes } from './app.routes';
@@ -79,6 +85,12 @@ const icons: IconDefinition[] = [
InfoCircleOutline, InfoCircleOutline,
ExclamationCircleOutline, ExclamationCircleOutline,
CheckCircleOutline, CheckCircleOutline,
UserAddOutline,
UserDeleteOutline,
PauseCircleOutline,
PlayCircleOutline,
StopOutline,
ArrowLeftOutline,
]; ];
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {

View File

@@ -23,6 +23,7 @@ export interface MessageListParams {
search?: string; search?: string;
sender?: string[]; sender?: string[];
subscription_status?: 'all' | 'confirmed' | 'unconfirmed'; subscription_status?: 'all' | 'confirmed' | 'unconfirmed';
used_key?: string;
trimmed?: boolean; trimmed?: boolean;
page_size?: number; page_size?: number;
next_page_token?: string; next_page_token?: string;

View File

@@ -153,6 +153,7 @@ export class ApiService {
} }
} }
if (params.subscription_status) httpParams = httpParams.set('subscription_status', params.subscription_status); if (params.subscription_status) httpParams = httpParams.set('subscription_status', params.subscription_status);
if (params.used_key) httpParams = httpParams.set('used_key', params.used_key);
if (params.trimmed !== undefined) httpParams = httpParams.set('trimmed', params.trimmed); if (params.trimmed !== undefined) httpParams = httpParams.set('trimmed', params.trimmed);
if (params.page_size) httpParams = httpParams.set('page_size', params.page_size); if (params.page_size) httpParams = httpParams.set('page_size', params.page_size);
if (params.next_page_token) httpParams = httpParams.set('next_page_token', params.next_page_token); if (params.next_page_token) httpParams = httpParams.set('next_page_token', params.next_page_token);

View File

@@ -150,7 +150,21 @@
</nz-card> </nz-card>
@if (isOwner()) { @if (isOwner()) {
<nz-card nzTitle="Subscriptions" class="mt-16"> <nz-card nzTitle="Subscriptions" [nzExtra]="subscriptionsCardExtra" class="mt-16">
<ng-template #subscriptionsCardExtra>
@if (expertMode()) {
<button
nz-button
nzSize="small"
[nzType]="isUserSubscribed() ? 'default' : 'primary'"
nz-tooltip
[nzTooltipTitle]="isUserSubscribed() ? 'Unsubscribe' : 'Subscribe'"
(click)="toggleSelfSubscription()"
>
<span nz-icon [nzType]="isUserSubscribed() ? 'user-delete' : 'user-add'"></span>
</button>
}
</ng-template>
<nz-table <nz-table
#subscriptionTable #subscriptionTable
[nzData]="subscriptions()" [nzData]="subscriptions()"
@@ -164,6 +178,7 @@
<tr> <tr>
<th>Subscriber</th> <th>Subscriber</th>
<th nzWidth="0">Status</th> <th nzWidth="0">Status</th>
<th nzWidth="0">Active</th>
<th nzWidth="0">Created</th> <th nzWidth="0">Created</th>
<th nzWidth="0">Actions</th> <th nzWidth="0">Actions</th>
</tr> </tr>
@@ -184,6 +199,13 @@
</nz-tag> </nz-tag>
</a> </a>
</td> </td>
<td>
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
<nz-tag [nzColor]="sub.active ? 'green' : 'default'">
{{ sub.active ? 'Active' : 'Inactive' }}
</nz-tag>
</a>
</td>
<td> <td>
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]"> <a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
<div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div> <div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
@@ -234,7 +256,7 @@
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="4"> <td colspan="5">
<nz-empty nzNotFoundContent="No subscriptions"></nz-empty> <nz-empty nzNotFoundContent="No subscriptions"></nz-empty>
</td> </td>
</tr> </tr>

View File

@@ -104,6 +104,9 @@
.message-content { .message-content {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
white-space: pre;
max-height: 2lh;
overflow-y: clip;
} }
.text-muted { .text-muted {

View File

@@ -390,4 +390,33 @@ export class ChannelDetailComponent implements OnInit {
} }
}); });
} }
isUserSubscribed(): boolean {
return this.channel()?.subscription !== null;
}
toggleSelfSubscription(): void {
const channel = this.channel();
const userId = this.authService.getUserId();
if (!channel || !userId) return;
if (this.isUserSubscribed()) {
// Unsubscribe
const subscriptionId = channel.subscription!.subscription_id;
this.apiService.deleteSubscription(userId, subscriptionId).subscribe({
next: () => {
this.notification.success('Unsubscribed from channel');
this.loadChannel(channel.channel_id);
}
});
} else {
// Subscribe
this.apiService.createSubscription(userId, { channel_id: channel.channel_id }).subscribe({
next: () => {
this.notification.success('Subscribed to channel');
this.loadChannel(channel.channel_id);
}
});
}
}
} }

View File

@@ -9,6 +9,21 @@
</div> </div>
</div> </div>
<nz-tabset (nzSelectedIndexChange)="onTabChange($event)">
<nz-tab nzTitle="All"></nz-tab>
<nz-tab nzTitle="Owned"></nz-tab>
<nz-tab nzTitle="Foreign"></nz-tab>
</nz-tabset>
@if (getTabDescription()) {
<nz-alert
nzType="info"
[nzMessage]="getTabDescription()!"
nzShowIcon
style="margin-bottom: 16px;"
></nz-alert>
}
<nz-card> <nz-card>
<nz-table <nz-table
#channelTable #channelTable
@@ -28,6 +43,9 @@
<th style="width: 400px">Subscribers</th> <th style="width: 400px">Subscribers</th>
<th style="width: 0">Messages</th> <th style="width: 0">Messages</th>
<th style="width: 0">Last Sent</th> <th style="width: 0">Last Sent</th>
@if (expertMode()) {
<th style="width: 0">Actions</th>
}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -56,10 +74,12 @@
<td> <td>
@if (isOwned(channel)) { @if (isOwned(channel)) {
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]"> <a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
{{ getOwnerDisplayName(channel.owner_user_id) }} <div class="channel-name">{{ getOwnerDisplayName(channel.owner_user_id) }}</div>
<div class="channel-id mono">{{ channel.owner_user_id }}</div>
</a> </a>
} @else { } @else {
{{ getOwnerDisplayName(channel.owner_user_id) }} <div class="channel-name">{{ getOwnerDisplayName(channel.owner_user_id) }}</div>
<div class="channel-id mono">{{ channel.owner_user_id }}</div>
} }
</td> </td>
<td> <td>
@@ -112,10 +132,26 @@
} }
} }
</td> </td>
@if (expertMode()) {
<td>
@if (isOwned(channel)) {
<button
nz-button
nzSize="small"
[nzType]="channel.subscription ? 'default' : 'primary'"
nz-tooltip
[nzTooltipTitle]="channel.subscription ? 'Unsubscribe' : 'Subscribe'"
(click)="toggleSelfSubscription(channel, $event)"
>
<span nz-icon [nzType]="channel.subscription ? 'user-delete' : 'user-add'"></span>
</button>
}
</td>
}
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="7"> <td [attr.colspan]="expertMode() ? 8 : 7">
<nz-empty nzNotFoundContent="No channels found"></nz-empty> <nz-empty nzNotFoundContent="No channels found"></nz-empty>
</td> </td>
</tr> </tr>

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal, OnInit } from '@angular/core'; import { Component, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common'; import { CommonModule, DatePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { NzTableModule } from 'ng-zorro-antd/table'; import { NzTableModule } from 'ng-zorro-antd/table';
@@ -9,13 +9,19 @@ import { NzBadgeModule } from 'ng-zorro-antd/badge';
import { NzEmptyModule } from 'ng-zorro-antd/empty'; import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzCardModule } from 'ng-zorro-antd/card'; import { NzCardModule } from 'ng-zorro-antd/card';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzTabsModule } from 'ng-zorro-antd/tabs';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { ApiService } from '../../../core/services/api.service'; import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.service';
import { SettingsService } from '../../../core/services/settings.service';
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
import { ChannelWithSubscription } from '../../../core/models'; import { ChannelWithSubscription } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subscribers.component'; import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subscribers.component';
type ChannelTab = 'all' | 'owned' | 'foreign';
@Component({ @Component({
selector: 'app-channel-list', selector: 'app-channel-list',
standalone: true, standalone: true,
@@ -31,6 +37,8 @@ import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subs
NzEmptyModule, NzEmptyModule,
NzCardModule, NzCardModule,
NzToolTipModule, NzToolTipModule,
NzTabsModule,
NzAlertModule,
RelativeTimePipe, RelativeTimePipe,
ChannelSubscribersComponent, ChannelSubscribersComponent,
], ],
@@ -40,12 +48,31 @@ import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subs
export class ChannelListComponent implements OnInit { export class ChannelListComponent implements OnInit {
private apiService = inject(ApiService); private apiService = inject(ApiService);
private authService = inject(AuthService); private authService = inject(AuthService);
private notification = inject(NotificationService);
private settingsService = inject(SettingsService);
private userCacheService = inject(UserCacheService); private userCacheService = inject(UserCacheService);
private router = inject(Router); private router = inject(Router);
channels = signal<ChannelWithSubscription[]>([]); allChannels = signal<ChannelWithSubscription[]>([]);
ownerNames = signal<Map<string, ResolvedUser>>(new Map()); ownerNames = signal<Map<string, ResolvedUser>>(new Map());
loading = signal(false); loading = signal(false);
expertMode = this.settingsService.expertMode;
activeTab = signal<ChannelTab>('all');
channels = computed(() => {
const userId = this.authService.getUserId();
const all = this.allChannels();
const tab = this.activeTab();
switch (tab) {
case 'owned':
return all.filter(c => c.owner_user_id === userId);
case 'foreign':
return all.filter(c => c.owner_user_id !== userId);
default:
return all;
}
});
ngOnInit(): void { ngOnInit(): void {
this.loadChannels(); this.loadChannels();
@@ -58,7 +85,7 @@ export class ChannelListComponent implements OnInit {
this.loading.set(true); this.loading.set(true);
this.apiService.getChannels(userId, 'all_any').subscribe({ this.apiService.getChannels(userId, 'all_any').subscribe({
next: (response) => { next: (response) => {
this.channels.set(response.channels); this.allChannels.set(response.channels);
this.loading.set(false); this.loading.set(false);
this.resolveOwnerNames(response.channels); this.resolveOwnerNames(response.channels);
}, },
@@ -68,6 +95,22 @@ export class ChannelListComponent implements OnInit {
}); });
} }
onTabChange(index: number): void {
const tabs: ChannelTab[] = ['all', 'owned', 'foreign'];
this.activeTab.set(tabs[index]);
}
getTabDescription(): string | null {
switch (this.activeTab()) {
case 'owned':
return 'Channels that you own and can configure.';
case 'foreign':
return 'Channels owned by other users that you are subscribed to.';
default:
return null;
}
}
private resolveOwnerNames(channels: ChannelWithSubscription[]): void { private resolveOwnerNames(channels: ChannelWithSubscription[]): void {
const uniqueOwnerIds = [...new Set(channels.map(c => c.owner_user_id))]; const uniqueOwnerIds = [...new Set(channels.map(c => c.owner_user_id))];
for (const ownerId of uniqueOwnerIds) { for (const ownerId of uniqueOwnerIds) {
@@ -109,4 +152,28 @@ export class ChannelListComponent implements OnInit {
return { label: 'Not Subscribed', color: 'default' }; return { label: 'Not Subscribed', color: 'default' };
} }
toggleSelfSubscription(channel: ChannelWithSubscription, event: Event): void {
event.stopPropagation();
const userId = this.authService.getUserId();
if (!userId) return;
if (channel.subscription) {
// Unsubscribe
this.apiService.deleteSubscription(userId, channel.subscription.subscription_id).subscribe({
next: () => {
this.notification.success('Unsubscribed from channel');
this.loadChannels();
}
});
} else {
// Subscribe
this.apiService.createSubscription(userId, { channel_id: channel.channel_id }).subscribe({
next: () => {
this.notification.success('Subscribed to channel');
this.loadChannels();
}
});
}
}
} }

View File

@@ -121,6 +121,9 @@
.message-content { .message-content {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
white-space: pre;
max-height: 2lh;
overflow-y: clip;
} }
.cell-name { .cell-name {

View File

@@ -131,17 +131,16 @@ export class KeyDetailComponent implements OnInit {
loadMessages(keyId: string, nextPageToken?: string): void { loadMessages(keyId: string, nextPageToken?: string): void {
this.loadingMessages.set(true); this.loadingMessages.set(true);
// Load more messages than page size to ensure we get enough after filtering
this.apiService.getMessages({ this.apiService.getMessages({
page_size: 64, subscription_status: 'all',
used_key: keyId,
page_size: this.messagesPageSize,
next_page_token: nextPageToken, next_page_token: nextPageToken,
trimmed: true trimmed: true
}).subscribe({ }).subscribe({
next: (response) => { next: (response) => {
// Filter messages by the key that was used to send them this.messages.set(response.messages);
const filtered = response.messages.filter(m => m.used_key_id === keyId); this.messagesTotalCount.set(response.total_count);
this.messages.set(filtered.slice(0, this.messagesPageSize));
this.messagesTotalCount.set(filtered.length);
this.messagesNextPageToken.set(response.next_page_token || null); this.messagesNextPageToken.set(response.next_page_token || null);
this.loadingMessages.set(false); this.loadingMessages.set(false);
}, },

View File

@@ -101,9 +101,7 @@
@for (delivery of deliveriesTable.data; track delivery.delivery_id) { @for (delivery of deliveriesTable.data; track delivery.delivery_id) {
<tr> <tr>
<td> <td>
<a [routerLink]="['/clients', delivery.receiver_client_id]" class="mono"> <span class="mono">{{ delivery.receiver_client_id }}</span>
{{ delivery.receiver_client_id }}
</a>
</td> </td>
<td> <td>
<nz-tag [nzColor]="getStatusColor(delivery.status)"> <nz-tag [nzColor]="getStatusColor(delivery.status)">

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal, OnInit, computed } from '@angular/core'; import { Component, inject, signal, OnInit, computed, effect } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common'; import { CommonModule, DatePipe } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { NzCardModule } from 'ng-zorro-antd/card'; import { NzCardModule } from 'ng-zorro-antd/card';
@@ -66,6 +66,15 @@ export class MessageDetailComponent implements OnInit {
showDeliveries = computed(() => this.expertMode() && this.isChannelOwner()); showDeliveries = computed(() => this.expertMode() && this.isChannelOwner());
constructor() {
// Watch for expert mode changes and load deliveries when it becomes visible
effect(() => {
if (this.showDeliveries() && this.message() && this.deliveries().length === 0 && !this.loadingDeliveries()) {
this.loadDeliveries(this.message()!.message_id);
}
});
}
ngOnInit(): void { ngOnInit(): void {
const messageId = this.route.snapshot.paramMap.get('id'); const messageId = this.route.snapshot.paramMap.get('id');
if (messageId) { if (messageId) {

View File

@@ -50,6 +50,9 @@
.message-content { .message-content {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
white-space: pre;
max-height: 2lh;
overflow-y: clip;
} }
.text-muted { .text-muted {

View File

@@ -16,7 +16,7 @@
Accept Accept
</button> </button>
} }
@if (subscription()!.confirmed && isOwner()) { @if (subscription()!.confirmed && isOwner() && !isOwnSubscription()) {
<button <button
nz-button nz-button
nz-popconfirm nz-popconfirm
@@ -27,7 +27,25 @@
Deactivate Deactivate
</button> </button>
} }
@if (expertMode() && isOutgoing() && subscription()!.confirmed && subscription()!.active) { @if (isOwnSubscription()) {
@if (subscription()!.active) {
<button
nz-button
nz-popconfirm
nzPopconfirmTitle="Deactivate this subscription?"
(nzOnConfirm)="setInactive()"
>
<span nz-icon nzType="pause-circle"></span>
Deactivate
</button>
} @else {
<button nz-button nzType="primary" (click)="activateSubscription()">
<span nz-icon nzType="play-circle"></span>
Activate
</button>
}
}
@if (expertMode() && isOutgoing() && subscription()!.confirmed && subscription()!.active && !isOwnSubscription()) {
<button <button
nz-button nz-button
nz-popconfirm nz-popconfirm

View File

@@ -118,6 +118,13 @@ export class SubscriptionDetailComponent implements OnInit {
return sub.channel_owner_user_id === userId; return sub.channel_owner_user_id === userId;
} }
isOwnSubscription(): boolean {
const sub = this.subscription();
if (!sub) return false;
const userId = this.authService.getUserId();
return sub.subscriber_user_id === userId && sub.channel_owner_user_id === userId;
}
getStatusInfo(): { label: string; color: string } { getStatusInfo(): { label: string; color: string } {
const sub = this.subscription(); const sub = this.subscription();
if (!sub) return { label: 'Unknown', color: 'default' }; if (!sub) return { label: 'Unknown', color: 'default' };
@@ -153,6 +160,19 @@ export class SubscriptionDetailComponent implements OnInit {
}); });
} }
activateSubscription(): void {
const sub = this.subscription();
const userId = this.authService.getUserId();
if (!sub || !userId) return;
this.apiService.confirmSubscription(userId, sub.subscription_id, { active: true }).subscribe({
next: (updated) => {
this.subscription.set(updated);
this.notification.success('Subscription activated');
}
});
}
deactivateSubscription(): void { deactivateSubscription(): void {
const sub = this.subscription(); const sub = this.subscription();
const userId = this.authService.getUserId(); const userId = this.authService.getUserId();

View File

@@ -133,6 +133,33 @@
<span nz-icon nzType="close"></span> <span nz-icon nzType="close"></span>
</button> </button>
} @else { } @else {
<!-- Own subscriptions: can activate/deactivate -->
@if (isOwnSubscription(sub)) {
@if (sub.active) {
<button
nz-button
nzSize="small"
nz-tooltip
nzTooltipTitle="Deactivate"
nz-popconfirm
nzPopconfirmTitle="Deactivate this subscription?"
(nzOnConfirm)="deactivateSubscription(sub)"
>
<span nz-icon nzType="pause-circle"></span>
</button>
} @else {
<button
nz-button
nzSize="small"
nzType="primary"
nz-tooltip
nzTooltipTitle="Activate"
(click)="activateSubscription(sub)"
>
<span nz-icon nzType="play-circle"></span>
</button>
}
}
<!-- Confirmed or outgoing: can revoke --> <!-- Confirmed or outgoing: can revoke -->
<button <button
nz-button nz-button

View File

@@ -159,6 +159,11 @@ export class SubscriptionListComponent implements OnInit {
return sub.channel_owner_user_id === userId; return sub.channel_owner_user_id === userId;
} }
isOwnSubscription(sub: Subscription): boolean {
const userId = this.authService.getUserId();
return sub.subscriber_user_id === userId && sub.channel_owner_user_id === userId;
}
viewSubscription(sub: Subscription): void { viewSubscription(sub: Subscription): void {
this.router.navigate(['/subscriptions', sub.subscription_id]); this.router.navigate(['/subscriptions', sub.subscription_id]);
} }
@@ -200,6 +205,30 @@ export class SubscriptionListComponent implements OnInit {
}); });
} }
activateSubscription(sub: Subscription): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.apiService.confirmSubscription(userId, sub.subscription_id, { active: true }).subscribe({
next: () => {
this.notification.success('Subscription activated');
this.loadSubscriptions();
}
});
}
deactivateSubscription(sub: Subscription): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.apiService.confirmSubscription(userId, sub.subscription_id, { active: false }).subscribe({
next: () => {
this.notification.success('Subscription deactivated');
this.loadSubscriptions();
}
});
}
// Create subscription // Create subscription
openCreateModal(): void { openCreateModal(): void {
this.newChannelOwner = ''; this.newChannelOwner = '';