diff --git a/docs-site/docs/plugin-development/capabilities.md b/docs-site/docs/plugin-development/capabilities.md index 85fec4a..65bf99c 100644 --- a/docs-site/docs/plugin-development/capabilities.md +++ b/docs-site/docs/plugin-development/capabilities.md @@ -19,11 +19,11 @@ Capabilities protect privileged app surfaces. A plugin must declare a capability | `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. | | `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. | | `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. | -| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. | +| `messages.sync` | `messages.sync()`, `messages.import()`, `attachments.import()` | Syncs message arrays, imports historical messages locally, or imports files for those messages. | | `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. | -| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. | +| `channels.manage` | `channels.addTextChannel()`, `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. | | `server.read` | `server.getCurrent()` | Reads active server. | -| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. | +| `server.manage` | `server.updateIcon()`, `server.updatePermissions()`, `server.updateSettings()` | Updates server icon, permissions, or settings. `server.updateIcon()` resolves when the local icon update has been persisted or rejects if the current user is not allowed to manage the server icon. | | `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. | | `p2p.media` | Reserved peer media features. | Included for media-facing plugins. | | `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. | diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index ea76557..19bc27d 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 8ab4d68..48a75b0 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -93,6 +93,16 @@ + @if (isMobile() && directCalls.mobileOverlaySession(); as call) { +
+ +
+ } + @if (isThemeStudioFullscreen()) {
([]); + private readonly mobileOverlayCallId = signal(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(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 { + if (this.viewport.isMobile()) { + await this.openMobileCallOverlay(callId); + return; + } + + await this.openCallView(callId); + } + + async openMobileCallOverlay(callId: string): Promise { + await this.openCall(callId); + this.mobileOverlayCallId.set(callId); + } + + closeMobileCallOverlay(): void { + this.mobileOverlayCallId.set(null); + } + async answerIncomingCall(callId: string): Promise { const session = this.sessionById(callId); diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html index 3fbfd17..20be99e 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html @@ -6,17 +6,39 @@ appThemeNode="dmChatHeader" class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4" > - -
-

{{ peerName() }}

-

{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}

-
+ @if (peerUser()) { + + } @else { + +
+

{{ peerName() }}

+

{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}

+
+ } @if (showCallButton() && conversation()) {
diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts index 6daee4c..1ca6310 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts @@ -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(); trackConversationId(index: number, conversation: DirectMessageConversation): string { return conversation.id; diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html index 6521cf0..020bc8a 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html @@ -13,7 +13,10 @@
- +
@@ -32,7 +35,21 @@ class="h-5 w-5" /> -

Direct messages

+

Direct messages

+ @if (activeCall()) { + + }
@@ -50,4 +67,3 @@
} - diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts index b982aa6..dd08d02 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts @@ -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>('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('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()); } diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts index 3a65d12..1484000 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-client-api.service.ts @@ -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 { + 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): void { void this.db.updateMessage(messageId, updates).catch((error: unknown) => { this.logger.warn(pluginId, 'Failed to persist plugin message update', error); diff --git a/toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts b/toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts index 3782cc0..1f8d8de 100644 --- a/toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts +++ b/toju-app/src/app/domains/plugins/domain/models/plugin-api.models.ts @@ -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; write: (key: string, value: unknown) => Promise; }; + readonly attachments: { + import: (request: PluginApiAttachmentImportRequest) => Promise; + }; readonly media: { addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise; addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise; @@ -235,6 +244,7 @@ export interface TojuClientPluginApi { readCurrent: () => Message[]; send: (content: string, channelId?: string) => Message; sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void; + import: (messages: Message[]) => Promise; 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; updatePermissions: (permissions: Partial) => void; updateSettings: (settings: PluginApiServerSettingsUpdate) => void; }; diff --git a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html index 3812e33..193d701 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html @@ -1,13 +1,13 @@
-
-
+
+
- - +
+ + +
-
+
@switch (activeTab()) { @case ('extensions') {
@@ -216,7 +218,7 @@ @for (entry of entries(); track trackEntry($index, entry)) { }
-
+
@if (selectedPlugin(); as plugin) {

{{ plugin.manifest.title }} settings

@if (selectedSettingsPages().length > 0) { @@ -255,7 +257,7 @@ @for (entry of entries(); track trackEntry($index, entry)) { }
-
+
@if (selectedPlugin(); as plugin) {

{{ plugin.manifest.title }}

{{ plugin.manifest.description }}

@for (doc of selectedDocs(); track doc.label) { @@ -323,7 +325,7 @@
@if (entries().length === 0) {
@@ -351,17 +353,17 @@

{{ entry.manifest.description }}

{{ entry.manifest.id }}

-
+
-