fix: Mobile style fixes and other small ui fixes
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
@@ -38,9 +39,11 @@ export class DirectCallService {
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
||||
private readonly mobileOverlayCallId = signal<string | null>(null);
|
||||
|
||||
readonly sessions = computed(() => this.sessionsSignal());
|
||||
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
||||
@@ -65,6 +68,15 @@ export class DirectCallService {
|
||||
});
|
||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||
readonly mobileOverlaySession = computed(() => {
|
||||
const callId = this.mobileOverlayCallId();
|
||||
|
||||
if (!callId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.delivery.directCallEvents$.subscribe((event) => {
|
||||
@@ -92,6 +104,12 @@ export class DirectCallService {
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.mobileOverlayCallId() && !this.mobileOverlaySession()) {
|
||||
this.mobileOverlayCallId.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sessionById(callId: string | null | undefined): DirectCallSession | null {
|
||||
@@ -155,7 +173,7 @@ export class DirectCallService {
|
||||
this.currentSession.set(session);
|
||||
await this.joinCall(session.callId, false);
|
||||
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
||||
await this.router.navigate(['/call', session.callId]);
|
||||
await this.openCallView(session.callId);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -186,6 +204,24 @@ export class DirectCallService {
|
||||
this.currentSession.set(session);
|
||||
}
|
||||
|
||||
async openCallView(callId: string): Promise<void> {
|
||||
if (this.viewport.isMobile()) {
|
||||
await this.openMobileCallOverlay(callId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.openCallView(callId);
|
||||
}
|
||||
|
||||
async openMobileCallOverlay(callId: string): Promise<void> {
|
||||
await this.openCall(callId);
|
||||
this.mobileOverlayCallId.set(callId);
|
||||
}
|
||||
|
||||
closeMobileCallOverlay(): void {
|
||||
this.mobileOverlayCallId.set(null);
|
||||
}
|
||||
|
||||
async answerIncomingCall(callId: string): Promise<void> {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
|
||||
@@ -6,17 +6,39 @@
|
||||
appThemeNode="dmChatHeader"
|
||||
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="peerName()"
|
||||
[avatarUrl]="peerUser()?.avatarUrl"
|
||||
[status]="peerUser()?.status"
|
||||
[showStatusBadge]="true"
|
||||
size="md"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
||||
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
|
||||
</div>
|
||||
@if (peerUser()) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 pr-2 text-left transition-colors hover:bg-secondary/60 focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
[attr.aria-label]="'Open profile for ' + peerName()"
|
||||
[title]="'Open profile for ' + peerName()"
|
||||
(click)="openHeaderProfileCard($event)"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="peerName()"
|
||||
[avatarUrl]="peerUser()?.avatarUrl"
|
||||
[status]="peerUser()?.status"
|
||||
[showStatusBadge]="true"
|
||||
size="md"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
||||
<p class="text-xs text-muted-foreground">Direct Message</p>
|
||||
</div>
|
||||
</button>
|
||||
} @else {
|
||||
<app-user-avatar
|
||||
[name]="peerName()"
|
||||
[avatarUrl]="peerUser()?.avatarUrl"
|
||||
[status]="peerUser()?.status"
|
||||
[showStatusBadge]="true"
|
||||
size="md"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
||||
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (showCallButton() && conversation()) {
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -16,7 +16,11 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { BottomSheetComponent, UserAvatarComponent } from '../../../../shared';
|
||||
import {
|
||||
BottomSheetComponent,
|
||||
ProfileCardService,
|
||||
UserAvatarComponent
|
||||
} from '../../../../shared';
|
||||
import { DirectCallService } from '../../../direct-call';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
@@ -82,6 +86,7 @@ export class DmChatComponent {
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly metadataRequestKeys = new Set<string>();
|
||||
private openedConversationId: string | null = null;
|
||||
@@ -309,6 +314,17 @@ export class DmChatComponent {
|
||||
}
|
||||
}
|
||||
|
||||
openHeaderProfileCard(event: MouseEvent): void {
|
||||
const user = this.peerUser();
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
this.profileCard.open(event.currentTarget as HTMLElement, user, { editable: false });
|
||||
}
|
||||
|
||||
setReplyTo(message: ChatMessageReplyEvent): void {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="group/server relative flex w-full justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground"
|
||||
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground md:h-10 md:w-10"
|
||||
title="Direct Messages"
|
||||
aria-label="Direct Messages"
|
||||
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="h-4 w-4"
|
||||
class="h-[18px] w-[18px] md:h-4 md:w-4"
|
||||
/>
|
||||
@if (directMessages.totalUnreadCount() > 0) {
|
||||
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="group/server relative flex w-full justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
|
||||
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card md:h-10 md:w-10"
|
||||
[class.dm-rail-slide-in]="!item.isExiting"
|
||||
[class.dm-rail-slide-out]="item.isExiting"
|
||||
[class.pointer-events-none]="item.isExiting"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div
|
||||
appThemeNode="dmConversationItem"
|
||||
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
|
||||
class="group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
|
||||
[class.bg-primary/10]="isSelected()"
|
||||
[class.text-foreground]="isSelected()"
|
||||
[attr.aria-current]="isSelected() ? 'page' : null"
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
input,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -48,6 +49,7 @@ export class DmConversationItemComponent {
|
||||
private readonly directMessages = inject(DirectMessageService);
|
||||
private readonly directCalls = inject(DirectCallService);
|
||||
readonly conversation = input.required<DirectMessageConversation>();
|
||||
readonly conversationOpened = output<string>();
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
@@ -71,6 +73,7 @@ export class DmConversationItemComponent {
|
||||
}
|
||||
|
||||
openConversation(): void {
|
||||
this.conversationOpened.emit(this.conversation().id);
|
||||
void this.router.navigate(['/dm', this.conversation().id]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<aside
|
||||
appThemeNode="dmConversationsPanel"
|
||||
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
||||
class="flex min-h-0 w-full min-w-0 overflow-hidden border-r border-border bg-card"
|
||||
[ngStyle]="listPanelStyles()"
|
||||
>
|
||||
<section class="flex h-full w-full min-w-0 flex-col">
|
||||
@@ -28,10 +28,12 @@
|
||||
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
||||
} @else {
|
||||
<div class="space-y-1">
|
||||
<app-dm-conversation-item
|
||||
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId"
|
||||
[conversation]="conversation"
|
||||
></app-dm-conversation-item>
|
||||
@for (conversation of directMessages.conversations(); track trackConversationId($index, conversation)) {
|
||||
<app-dm-conversation-item
|
||||
[conversation]="conversation"
|
||||
(conversationOpened)="conversationSelected.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
inject,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -31,6 +32,7 @@ export class DmConversationsPanelComponent {
|
||||
private readonly theme = inject(ThemeService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||
readonly conversationSelected = output<string>();
|
||||
|
||||
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
||||
return conversation.id;
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
<div class="flex h-full w-full min-h-0 overflow-hidden">
|
||||
<app-servers-rail class="block h-full shrink-0" />
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
|
||||
<app-dm-conversations-panel class="block h-full w-full" />
|
||||
<app-dm-conversations-panel
|
||||
(conversationSelected)="setMobilePage('chat')"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
@@ -32,7 +35,21 @@
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
<p class="truncate text-sm font-semibold text-foreground">Direct messages</p>
|
||||
<p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">Direct messages</p>
|
||||
@if (activeCall()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="openActiveCall()"
|
||||
class="grid h-11 w-11 place-items-center rounded-lg text-emerald-600 transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
|
||||
aria-label="Return to call"
|
||||
title="Return to call"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneCall"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<app-dm-chat-panel class="block h-full w-full" />
|
||||
@@ -50,4 +67,3 @@
|
||||
<app-dm-chat-panel />
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideChevronLeft } from '@ng-icons/lucide';
|
||||
import { lucideChevronLeft, lucidePhoneCall } from '@ng-icons/lucide';
|
||||
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { ThemeService } from '../../../theme';
|
||||
import { DirectCallService } from '../../../direct-call';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { DmChatPanelComponent } from './dm-chat-panel.component';
|
||||
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
|
||||
@@ -47,7 +48,7 @@ interface SwiperElement extends HTMLElement {
|
||||
DmConversationsPanelComponent,
|
||||
ServersRailComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideChevronLeft })],
|
||||
viewProviders: [provideIcons({ lucideChevronLeft, lucidePhoneCall })],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
templateUrl: './dm-workspace.component.html'
|
||||
})
|
||||
@@ -57,6 +58,7 @@ export class DmWorkspaceComponent implements OnDestroy {
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly zone = inject(NgZone);
|
||||
private readonly directCalls = inject(DirectCallService);
|
||||
private lastSeenConversationId: string | null = null;
|
||||
private swiperListenerAttached: SwiperElement | null = null;
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
@@ -66,6 +68,12 @@ export class DmWorkspaceComponent implements OnDestroy {
|
||||
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
||||
readonly activeCall = computed(() => {
|
||||
const currentSession = this.directCalls.currentSession();
|
||||
const visibleSessions = this.directCalls.visibleActiveSessions();
|
||||
|
||||
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
|
||||
});
|
||||
|
||||
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
||||
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
|
||||
@@ -150,6 +158,14 @@ export class DmWorkspaceComponent implements OnDestroy {
|
||||
this.mobilePage.set(page);
|
||||
}
|
||||
|
||||
openActiveCall(): void {
|
||||
const call = this.activeCall();
|
||||
|
||||
if (call) {
|
||||
void this.directCalls.openCallView(call.callId);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.directMessages.closeConversationView(this.routeConversationId());
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Store } from '@ngrx/store';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { resolveRoomPermission } from '../../../access-control';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
||||
import type {
|
||||
Channel,
|
||||
@@ -28,6 +31,7 @@ import type {
|
||||
PluginApiAvatarUpdate,
|
||||
PluginApiActionContext,
|
||||
PluginApiActionSource,
|
||||
PluginApiAttachmentImportRequest,
|
||||
PluginApiChannelRequest,
|
||||
PluginApiCustomStreamRequest,
|
||||
PluginApiMessageAsPluginUserRequest,
|
||||
@@ -44,11 +48,13 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginClientApiService {
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly capabilities = inject(PluginCapabilityService);
|
||||
private readonly db = inject(DatabaseService);
|
||||
private readonly logger = inject(PluginLoggerService);
|
||||
private readonly messageBus = inject(PluginMessageBusService);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly store = inject(Store);
|
||||
private readonly storage = inject(PluginStorageService);
|
||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||
@@ -73,6 +79,10 @@ export class PluginClientApiService {
|
||||
requireCapability('channels.manage');
|
||||
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
|
||||
},
|
||||
addTextChannel: (request) => {
|
||||
requireCapability('channels.manage');
|
||||
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'text') }));
|
||||
},
|
||||
addVideoChannel: (request) => {
|
||||
requireCapability('channels.manage');
|
||||
this.uiRegistry.registerChannelSection(pluginId, request.id ?? request.name, {
|
||||
@@ -143,6 +153,15 @@ export class PluginClientApiService {
|
||||
await this.storage.writeClientData(pluginId, key, value);
|
||||
}
|
||||
},
|
||||
attachments: {
|
||||
import: async (request: PluginApiAttachmentImportRequest) => {
|
||||
requireCapability('messages.sync');
|
||||
const roomId = this.requireRoomId();
|
||||
|
||||
this.attachments.rememberMessageRoom(request.messageId, roomId);
|
||||
await this.attachments.publishAttachments(request.messageId, request.files, this.currentUser()?.id);
|
||||
}
|
||||
},
|
||||
media: {
|
||||
addCustomAudioStream: async (request) => {
|
||||
requireCapability('media.addAudioStream');
|
||||
@@ -190,6 +209,10 @@ export class PluginClientApiService {
|
||||
requireCapability('messages.send');
|
||||
this.receivePluginUserMessage(pluginId, request);
|
||||
},
|
||||
import: async (messages) => {
|
||||
requireCapability('messages.sync');
|
||||
await this.importPluginMessages(pluginId, messages);
|
||||
},
|
||||
setTyping: (isTyping, channelId) => {
|
||||
requireCapability('messages.send');
|
||||
this.setTyping(pluginId, isTyping, channelId);
|
||||
@@ -301,6 +324,58 @@ export class PluginClientApiService {
|
||||
|
||||
return userId;
|
||||
},
|
||||
updateIcon: async (icon) => {
|
||||
requireCapability('server.manage');
|
||||
const room = this.currentRoom();
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!room) {
|
||||
throw new Error('Room not found');
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
|
||||
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
||||
const isServerAdmin = currentUser.role === 'admin' || currentUser.role === 'host';
|
||||
const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon');
|
||||
|
||||
if (!isOwner && !isServerAdmin && !canByRole) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
const iconUpdatedAt = Date.now();
|
||||
|
||||
await this.db.updateRoom(room.id, { icon, iconUpdatedAt });
|
||||
|
||||
this.store.dispatch(RoomsActions.updateServerIconSuccess({ roomId: room.id, icon, iconUpdatedAt }));
|
||||
|
||||
this.realtime.broadcastMessage({
|
||||
type: 'server-icon-update',
|
||||
roomId: room.id,
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
});
|
||||
|
||||
this.realtime.sendRawMessage({
|
||||
type: 'server_icon_available',
|
||||
serverId: room.id,
|
||||
iconUpdatedAt
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
actingRole: isOwner ? 'host' : undefined,
|
||||
currentOwnerId: currentUser.id,
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
}, {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
}).subscribe({
|
||||
error: () => {}
|
||||
});
|
||||
},
|
||||
updatePermissions: (permissions) => {
|
||||
requireCapability('server.manage');
|
||||
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
|
||||
@@ -648,6 +723,29 @@ export class PluginClientApiService {
|
||||
});
|
||||
}
|
||||
|
||||
private async importPluginMessages(pluginId: string, messages: Message[]): Promise<void> {
|
||||
const roomId = this.requireRoomId();
|
||||
const normalizedMessages = messages
|
||||
.filter((message) => message.roomId === roomId)
|
||||
.map((message) => ({
|
||||
...message,
|
||||
channelId: message.channelId ?? this.activeChannelId() ?? 'general',
|
||||
isDeleted: message.isDeleted === true,
|
||||
reactions: message.reactions ?? []
|
||||
}));
|
||||
|
||||
if (normalizedMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of normalizedMessages) {
|
||||
await this.db.saveMessage(message);
|
||||
}
|
||||
|
||||
this.store.dispatch(MessagesActions.syncMessages({ messages: normalizedMessages }));
|
||||
this.logger.info(pluginId, 'Historical messages imported', { count: normalizedMessages.length });
|
||||
}
|
||||
|
||||
private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void {
|
||||
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
|
||||
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);
|
||||
|
||||
@@ -74,6 +74,11 @@ export interface PluginApiAudioClipRequest {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginApiAttachmentImportRequest {
|
||||
files: File[];
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface PluginApiCustomStreamRequest {
|
||||
label?: string;
|
||||
stream: MediaStream;
|
||||
@@ -195,6 +200,7 @@ export interface PluginApiUiContributionMap {
|
||||
export interface TojuClientPluginApi {
|
||||
readonly channels: {
|
||||
addAudioChannel: (request: PluginApiChannelRequest) => void;
|
||||
addTextChannel: (request: PluginApiChannelRequest) => void;
|
||||
addVideoChannel: (request: PluginApiChannelRequest) => void;
|
||||
list: () => Channel[];
|
||||
remove: (channelId: string) => void;
|
||||
@@ -221,6 +227,9 @@ export interface TojuClientPluginApi {
|
||||
remove: (key: string) => Promise<void>;
|
||||
write: (key: string, value: unknown) => Promise<void>;
|
||||
};
|
||||
readonly attachments: {
|
||||
import: (request: PluginApiAttachmentImportRequest) => Promise<void>;
|
||||
};
|
||||
readonly media: {
|
||||
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
||||
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
||||
@@ -235,6 +244,7 @@ export interface TojuClientPluginApi {
|
||||
readCurrent: () => Message[];
|
||||
send: (content: string, channelId?: string) => Message;
|
||||
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
|
||||
import: (messages: Message[]) => Promise<void>;
|
||||
setTyping: (isTyping: boolean, channelId?: string) => void;
|
||||
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
|
||||
sync: (messages: Message[]) => void;
|
||||
@@ -261,6 +271,7 @@ export interface TojuClientPluginApi {
|
||||
readonly server: {
|
||||
getCurrent: () => Room | null;
|
||||
registerPluginUser: (request: PluginApiPluginUserRequest) => string;
|
||||
updateIcon: (icon: string) => Promise<void>;
|
||||
updatePermissions: (permissions: Partial<RoomPermissions>) => void;
|
||||
updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<section
|
||||
class="flex h-full min-h-0 flex-col bg-background text-foreground"
|
||||
class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
|
||||
data-testid="plugin-manager"
|
||||
>
|
||||
<header class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<header class="flex flex-col gap-3 border-b border-border px-3 py-3 md:flex-row md:items-center md:justify-between md:px-4">
|
||||
<div class="flex min-w-0 items-center gap-3 md:flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
class="inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground md:h-8 md:w-8"
|
||||
aria-label="Back to settings"
|
||||
(click)="close()"
|
||||
>
|
||||
@@ -21,38 +21,40 @@
|
||||
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50"
|
||||
[disabled]="busyAll()"
|
||||
(click)="activateAll()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlay"
|
||||
size="16"
|
||||
/>
|
||||
Activate ready plugins
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted"
|
||||
(click)="openStore()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideStore"
|
||||
size="16"
|
||||
/>
|
||||
Open Plugin Store
|
||||
</button>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 md:flex md:flex-shrink-0 md:items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
|
||||
[disabled]="busyAll()"
|
||||
(click)="activateAll()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlay"
|
||||
size="16"
|
||||
/>
|
||||
Activate ready plugins
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
|
||||
(click)="openStore()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideStore"
|
||||
size="16"
|
||||
/>
|
||||
Open Plugin Store
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav
|
||||
class="flex gap-2 border-b border-border px-4 py-2"
|
||||
class="no-scrollbar flex gap-2 overflow-x-auto border-b border-border px-3 py-2 md:px-4"
|
||||
aria-label="Plugin manager sections"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
||||
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||
[class.bg-muted]="activeTab() === 'installed'"
|
||||
(click)="setTab('installed')"
|
||||
>
|
||||
@@ -64,7 +66,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
||||
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||
[class.bg-muted]="activeTab() === 'extensions'"
|
||||
(click)="setTab('extensions')"
|
||||
>
|
||||
@@ -76,7 +78,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
||||
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||
[class.bg-muted]="activeTab() === 'requirements'"
|
||||
(click)="setTab('requirements')"
|
||||
>
|
||||
@@ -88,7 +90,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
||||
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||
[class.bg-muted]="activeTab() === 'settings'"
|
||||
(click)="setTab('settings')"
|
||||
>
|
||||
@@ -100,7 +102,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
||||
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||
[class.bg-muted]="activeTab() === 'docs'"
|
||||
(click)="setTab('docs')"
|
||||
>
|
||||
@@ -112,7 +114,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm"
|
||||
class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
|
||||
[class.bg-muted]="activeTab() === 'logs'"
|
||||
(click)="setTab('logs')"
|
||||
>
|
||||
@@ -124,7 +126,7 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div class="min-h-0 flex-1 overflow-auto p-3 md:p-4">
|
||||
@switch (activeTab()) {
|
||||
@case ('extensions') {
|
||||
<div class="space-y-4">
|
||||
@@ -216,7 +218,7 @@
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
|
||||
class="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
|
||||
[class.bg-muted]="isSelected(entry)"
|
||||
(click)="selectPlugin(entry.manifest.id)"
|
||||
>
|
||||
@@ -224,7 +226,7 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<section class="rounded-lg border border-border bg-card p-4">
|
||||
<section class="rounded-lg border border-border bg-card p-3 md:p-4">
|
||||
@if (selectedPlugin(); as plugin) {
|
||||
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
|
||||
@if (selectedSettingsPages().length > 0) {
|
||||
@@ -255,7 +257,7 @@
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted"
|
||||
class="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
|
||||
[class.bg-muted]="isSelected(entry)"
|
||||
(click)="selectPlugin(entry.manifest.id)"
|
||||
>
|
||||
@@ -263,14 +265,14 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<section class="rounded-lg border border-border bg-card p-4">
|
||||
<section class="rounded-lg border border-border bg-card p-3 md:p-4">
|
||||
@if (selectedPlugin(); as plugin) {
|
||||
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@for (doc of selectedDocs(); track doc.label) {
|
||||
<a
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
class="inline-flex min-h-11 items-center rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted md:min-h-0"
|
||||
[href]="doc.url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -292,7 +294,7 @@
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted"
|
||||
class="min-h-11 rounded-md border border-border px-3 py-1 text-sm hover:bg-muted md:min-h-0"
|
||||
[class.bg-muted]="isSelected(entry)"
|
||||
(click)="selectPlugin(entry.manifest.id)"
|
||||
>
|
||||
@@ -323,7 +325,7 @@
|
||||
<div class="space-y-3">
|
||||
@if (entries().length === 0) {
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-border p-8 text-center"
|
||||
class="rounded-lg border border-dashed border-border p-5 text-center md:p-8"
|
||||
data-testid="plugin-empty-state"
|
||||
>
|
||||
<ng-icon
|
||||
@@ -337,7 +339,7 @@
|
||||
} @else {
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
<article
|
||||
class="rounded-lg border border-border bg-card p-4"
|
||||
class="rounded-lg border border-border bg-card p-3 md:p-4"
|
||||
[class.ring-2]="isSelected(entry)"
|
||||
[class.ring-primary]="isSelected(entry)"
|
||||
>
|
||||
@@ -351,17 +353,17 @@
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p>
|
||||
<p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
|
||||
(click)="selectPlugin(entry.manifest.id)"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
|
||||
(click)="setEnabled(entry, !entry.enabled)"
|
||||
>
|
||||
<ng-icon
|
||||
@@ -372,7 +374,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
|
||||
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
|
||||
(click)="activate(entry)"
|
||||
>
|
||||
@@ -384,7 +386,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
|
||||
[disabled]="busyPluginId() === entry.manifest.id"
|
||||
(click)="reload(entry)"
|
||||
>
|
||||
@@ -396,7 +398,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
|
||||
[disabled]="busyPluginId() === entry.manifest.id"
|
||||
(click)="unload(entry)"
|
||||
>
|
||||
@@ -416,7 +418,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-lg border border-border bg-card p-4">
|
||||
<aside class="rounded-lg border border-border bg-card p-3 md:p-4">
|
||||
@if (selectedPlugin(); as plugin) {
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-icon
|
||||
@@ -430,14 +432,14 @@
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 h-8 rounded-md border border-border px-3 text-sm hover:bg-muted"
|
||||
class="mt-3 min-h-11 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
|
||||
(click)="grantAll(plugin)"
|
||||
>
|
||||
Grant all requested
|
||||
</button>
|
||||
<div class="mt-3 space-y-2">
|
||||
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
|
||||
<label class="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
||||
<label class="flex min-h-11 items-center gap-2 rounded-md border border-border px-3 py-2 text-sm md:min-h-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
|
||||
Reference in New Issue
Block a user