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

View File

@@ -23,6 +23,7 @@ export interface MessageListParams {
search?: string;
sender?: string[];
subscription_status?: 'all' | 'confirmed' | 'unconfirmed';
used_key?: string;
trimmed?: boolean;
page_size?: number;
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.used_key) httpParams = httpParams.set('used_key', params.used_key);
if (params.trimmed !== undefined) httpParams = httpParams.set('trimmed', params.trimmed);
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);

View File

@@ -150,7 +150,21 @@
</nz-card>
@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
#subscriptionTable
[nzData]="subscriptions()"
@@ -164,6 +178,7 @@
<tr>
<th>Subscriber</th>
<th nzWidth="0">Status</th>
<th nzWidth="0">Active</th>
<th nzWidth="0">Created</th>
<th nzWidth="0">Actions</th>
</tr>
@@ -184,6 +199,13 @@
</nz-tag>
</a>
</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>
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
<div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
@@ -234,7 +256,7 @@
</tr>
} @empty {
<tr>
<td colspan="4">
<td colspan="5">
<nz-empty nzNotFoundContent="No subscriptions"></nz-empty>
</td>
</tr>

View File

@@ -104,6 +104,9 @@
.message-content {
font-size: 12px;
color: #666;
white-space: pre;
max-height: 2lh;
overflow-y: clip;
}
.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>
<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-table
#channelTable
@@ -28,6 +43,9 @@
<th style="width: 400px">Subscribers</th>
<th style="width: 0">Messages</th>
<th style="width: 0">Last Sent</th>
@if (expertMode()) {
<th style="width: 0">Actions</th>
}
</tr>
</thead>
<tbody>
@@ -56,10 +74,12 @@
<td>
@if (isOwned(channel)) {
<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>
} @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>
@@ -112,10 +132,26 @@
}
}
</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>
} @empty {
<tr>
<td colspan="7">
<td [attr.colspan]="expertMode() ? 8 : 7">
<nz-empty nzNotFoundContent="No channels found"></nz-empty>
</td>
</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 { Router, RouterLink } from '@angular/router';
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 { NzCardModule } from 'ng-zorro-antd/card';
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 { 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 { ChannelWithSubscription } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subscribers.component';
type ChannelTab = 'all' | 'owned' | 'foreign';
@Component({
selector: 'app-channel-list',
standalone: true,
@@ -31,6 +37,8 @@ import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subs
NzEmptyModule,
NzCardModule,
NzToolTipModule,
NzTabsModule,
NzAlertModule,
RelativeTimePipe,
ChannelSubscribersComponent,
],
@@ -40,12 +48,31 @@ import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subs
export class ChannelListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private notification = inject(NotificationService);
private settingsService = inject(SettingsService);
private userCacheService = inject(UserCacheService);
private router = inject(Router);
channels = signal<ChannelWithSubscription[]>([]);
allChannels = signal<ChannelWithSubscription[]>([]);
ownerNames = signal<Map<string, ResolvedUser>>(new Map());
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 {
this.loadChannels();
@@ -58,7 +85,7 @@ export class ChannelListComponent implements OnInit {
this.loading.set(true);
this.apiService.getChannels(userId, 'all_any').subscribe({
next: (response) => {
this.channels.set(response.channels);
this.allChannels.set(response.channels);
this.loading.set(false);
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 {
const uniqueOwnerIds = [...new Set(channels.map(c => c.owner_user_id))];
for (const ownerId of uniqueOwnerIds) {
@@ -109,4 +152,28 @@ export class ChannelListComponent implements OnInit {
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 {
font-size: 12px;
color: #666;
white-space: pre;
max-height: 2lh;
overflow-y: clip;
}
.cell-name {

View File

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

View File

@@ -101,9 +101,7 @@
@for (delivery of deliveriesTable.data; track delivery.delivery_id) {
<tr>
<td>
<a [routerLink]="['/clients', delivery.receiver_client_id]" class="mono">
{{ delivery.receiver_client_id }}
</a>
<span class="mono">{{ delivery.receiver_client_id }}</span>
</td>
<td>
<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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { NzCardModule } from 'ng-zorro-antd/card';
@@ -66,6 +66,15 @@ export class MessageDetailComponent implements OnInit {
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 {
const messageId = this.route.snapshot.paramMap.get('id');
if (messageId) {

View File

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

View File

@@ -16,7 +16,7 @@
Accept
</button>
}
@if (subscription()!.confirmed && isOwner()) {
@if (subscription()!.confirmed && isOwner() && !isOwnSubscription()) {
<button
nz-button
nz-popconfirm
@@ -27,7 +27,25 @@
Deactivate
</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
nz-button
nz-popconfirm

View File

@@ -118,6 +118,13 @@ export class SubscriptionDetailComponent implements OnInit {
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 } {
const sub = this.subscription();
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 {
const sub = this.subscription();
const userId = this.authService.getUserId();

View File

@@ -133,6 +133,33 @@
<span nz-icon nzType="close"></span>
</button>
} @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 -->
<button
nz-button

View File

@@ -159,6 +159,11 @@ export class SubscriptionListComponent implements OnInit {
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 {
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
openCreateModal(): void {
this.newChannelOwner = '';