diff --git a/electron/app/lifecycle.ts b/electron/app/lifecycle.ts index 7d274ec..41debd5 100644 --- a/electron/app/lifecycle.ts +++ b/electron/app/lifecycle.ts @@ -4,7 +4,7 @@ import { destroyDatabase, getDataSource } from '../db/database'; -import { createWindow } from '../window/create-window'; +import { createWindow, getDockIconPath } from '../window/create-window'; import { setupCqrsHandlers, setupSystemHandlers, @@ -13,6 +13,11 @@ import { export function registerAppLifecycle(): void { app.whenReady().then(async () => { + const dockIconPath = getDockIconPath(); + + if (process.platform === 'darwin' && dockIconPath) + app.dock?.setIcon(dockIconPath); + await initializeDatabase(); setupCqrsHandlers(); setupWindowControlHandlers(); diff --git a/electron/cqrs/queries/handlers/isUserBanned.ts b/electron/cqrs/queries/handlers/isUserBanned.ts index 840889e..04293cb 100644 --- a/electron/cqrs/queries/handlers/isUserBanned.ts +++ b/electron/cqrs/queries/handlers/isUserBanned.ts @@ -12,5 +12,5 @@ export async function handleIsUserBanned(query: IsUserBannedQuery, dataSource: D .andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now }) .getMany(); - return rows.some((row) => row.oderId === userId); + return rows.some((row) => row.userId === userId || (!row.userId && row.oderId === userId)); } diff --git a/electron/window/create-window.ts b/electron/window/create-window.ts index 4eee168..8abc55a 100644 --- a/electron/window/create-window.ts +++ b/electron/window/create-window.ts @@ -1,13 +1,44 @@ -import { BrowserWindow, shell } from 'electron'; +import { app, BrowserWindow, shell } from 'electron'; +import * as fs from 'fs'; import * as path from 'path'; let mainWindow: BrowserWindow | null = null; +function getAssetPath(...segments: string[]): string { + const basePath = app.isPackaged + ? path.join(process.resourcesPath, 'images') + : path.join(__dirname, '..', '..', '..', 'images'); + + return path.join(basePath, ...segments); +} + +function getExistingAssetPath(...segments: string[]): string | undefined { + const assetPath = getAssetPath(...segments); + + return fs.existsSync(assetPath) ? assetPath : undefined; +} + +function getWindowIconPath(): string | undefined { + if (process.platform === 'win32') + return getExistingAssetPath('windows', 'icon.ico'); + + if (process.platform === 'linux') + return getExistingAssetPath('icon.png'); + + return undefined; +} + +export function getDockIconPath(): string | undefined { + return getExistingAssetPath('macos', '1024x1024.png'); +} + export function getMainWindow(): BrowserWindow | null { return mainWindow; } export async function createWindow(): Promise { + const windowIconPath = getWindowIconPath(); + mainWindow = new BrowserWindow({ width: 1400, height: 900, @@ -16,6 +47,7 @@ export async function createWindow(): Promise { frame: false, titleBarStyle: 'hidden', backgroundColor: '#0a0a0f', + ...(windowIconPath ? { icon: windowIconPath } : {}), webPreferences: { nodeIntegration: false, contextIsolation: true, diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000..a08bd90 Binary files /dev/null and b/images/icon.png differ diff --git a/images/linux/icons/128x128.png b/images/linux/icons/128x128.png new file mode 100644 index 0000000..9107eb5 Binary files /dev/null and b/images/linux/icons/128x128.png differ diff --git a/images/linux/icons/16x16.png b/images/linux/icons/16x16.png new file mode 100644 index 0000000..79d7fcb Binary files /dev/null and b/images/linux/icons/16x16.png differ diff --git a/images/linux/icons/256x256.png b/images/linux/icons/256x256.png new file mode 100644 index 0000000..2740569 Binary files /dev/null and b/images/linux/icons/256x256.png differ diff --git a/images/linux/icons/32x32.png b/images/linux/icons/32x32.png new file mode 100644 index 0000000..1840ac1 Binary files /dev/null and b/images/linux/icons/32x32.png differ diff --git a/images/linux/icons/48x48.png b/images/linux/icons/48x48.png new file mode 100644 index 0000000..0a1625b Binary files /dev/null and b/images/linux/icons/48x48.png differ diff --git a/images/linux/icons/512x512.png b/images/linux/icons/512x512.png new file mode 100644 index 0000000..26136ee Binary files /dev/null and b/images/linux/icons/512x512.png differ diff --git a/images/linux/icons/64x64.png b/images/linux/icons/64x64.png new file mode 100644 index 0000000..2afd69d Binary files /dev/null and b/images/linux/icons/64x64.png differ diff --git a/images/macos/1024x1024.png b/images/macos/1024x1024.png new file mode 100644 index 0000000..3cad2b0 Binary files /dev/null and b/images/macos/1024x1024.png differ diff --git a/images/macos/128x128.png b/images/macos/128x128.png new file mode 100644 index 0000000..0d48e5a Binary files /dev/null and b/images/macos/128x128.png differ diff --git a/images/macos/16x16.png b/images/macos/16x16.png new file mode 100644 index 0000000..cceeb38 Binary files /dev/null and b/images/macos/16x16.png differ diff --git a/images/macos/256x256.png b/images/macos/256x256.png new file mode 100644 index 0000000..913fb41 Binary files /dev/null and b/images/macos/256x256.png differ diff --git a/images/macos/32x32.png b/images/macos/32x32.png new file mode 100644 index 0000000..1b781d0 Binary files /dev/null and b/images/macos/32x32.png differ diff --git a/images/macos/512x512.png b/images/macos/512x512.png new file mode 100644 index 0000000..26136ee Binary files /dev/null and b/images/macos/512x512.png differ diff --git a/images/macos/64x64.png b/images/macos/64x64.png new file mode 100644 index 0000000..50ceb97 Binary files /dev/null and b/images/macos/64x64.png differ diff --git a/images/macos/icon.icns b/images/macos/icon.icns new file mode 100644 index 0000000..ca4bd2d Binary files /dev/null and b/images/macos/icon.icns differ diff --git a/images/windows/128x128.png b/images/windows/128x128.png new file mode 100644 index 0000000..4c9550e Binary files /dev/null and b/images/windows/128x128.png differ diff --git a/images/windows/16x16.png b/images/windows/16x16.png new file mode 100644 index 0000000..518e766 Binary files /dev/null and b/images/windows/16x16.png differ diff --git a/images/windows/256x256.png b/images/windows/256x256.png new file mode 100644 index 0000000..913fb41 Binary files /dev/null and b/images/windows/256x256.png differ diff --git a/images/windows/32x32.png b/images/windows/32x32.png new file mode 100644 index 0000000..b71ce1b Binary files /dev/null and b/images/windows/32x32.png differ diff --git a/images/windows/48x48.png b/images/windows/48x48.png new file mode 100644 index 0000000..c2eaaf2 Binary files /dev/null and b/images/windows/48x48.png differ diff --git a/images/windows/64x64.png b/images/windows/64x64.png new file mode 100644 index 0000000..fe44316 Binary files /dev/null and b/images/windows/64x64.png differ diff --git a/images/windows/icon.ico b/images/windows/icon.ico new file mode 100644 index 0000000..9153ac5 Binary files /dev/null and b/images/windows/icon.ico differ diff --git a/package.json b/package.json index 1065f91..926c436 100644 --- a/package.json +++ b/package.json @@ -119,16 +119,27 @@ "!node_modules/**/*.d.ts", "!node_modules/**/*.map" ], + "extraResources": [ + { + "from": "images", + "to": "images", + "filter": [ + "**/*" + ] + } + ], "nodeGypRebuild": false, "buildDependenciesFromSource": false, "npmRebuild": false, "mac": { "category": "public.app-category.social-networking", - "target": "dmg" + "target": "dmg", + "icon": "images/macos/icon.icns" }, "win": { "target": "nsis", - "artifactName": "${productName}-${version}-${arch}.${ext}" + "artifactName": "${productName}-${version}-${arch}.${ext}", + "icon": "images/windows/icon.ico" }, "nsis": { "oneClick": false, @@ -143,7 +154,8 @@ "executableName": "metoyou", "executableArgs": [ "--no-sandbox" - ] + ], + "icon": "images/linux/icons" } } } diff --git a/public/favicon.ico b/public/favicon.ico index 57614f9..9153ac5 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index ce12700..71c5928 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -4,6 +4,7 @@ import { ServerPayload, JoinRequestPayload } from '../cqrs/types'; import { getAllPublicServers, getServerById, + getUserById, upsertServer, deleteServer, createJoinRequest, @@ -13,6 +14,16 @@ import { notifyServerOwner } from '../websocket/broadcast'; const router = Router(); +async function enrichServer(server: ServerPayload) { + const owner = await getUserById(server.ownerId); + + return { + ...server, + ownerName: owner?.displayName, + userCount: server.currentUsers + }; +} + router.get('/', async (req, res) => { const { q, tags, limit = 20, offset = 0 } = req.query; @@ -37,7 +48,9 @@ router.get('/', async (req, res) => { results = results.slice(Number(offset), Number(offset) + Number(limit)); - res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) }); + const enrichedResults = await Promise.all(results.map((server) => enrichServer(server))); + + res.json({ servers: enrichedResults, total, limit: Number(limit), offset: Number(offset) }); }); router.post('/', async (req, res) => { diff --git a/src/app/core/helpers/room-ban.helpers.ts b/src/app/core/helpers/room-ban.helpers.ts new file mode 100644 index 0000000..755b99d --- /dev/null +++ b/src/app/core/helpers/room-ban.helpers.ts @@ -0,0 +1,50 @@ +import { + BanEntry, + User +} from '../models/index'; + +type BanAwareUser = Pick | null | undefined; + +/** Build the set of user identifiers that may appear in room ban entries. */ +export function getRoomBanCandidateIds(user: BanAwareUser, persistedUserId?: string | null): string[] { + const candidates = [ + user?.id, + user?.oderId, + persistedUserId + ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + + return Array.from(new Set(candidates)); +} + +/** Resolve the user identifier stored by a ban entry, with legacy fallback support. */ +export function getRoomBanTargetId(ban: Pick): string { + if (typeof ban.userId === 'string' && ban.userId.trim().length > 0) { + return ban.userId; + } + + return ban.oderId; +} + +/** Return true when the given ban targets the provided user. */ +export function isRoomBanMatch( + ban: Pick, + user: BanAwareUser, + persistedUserId?: string | null +): boolean { + const candidateIds = getRoomBanCandidateIds(user, persistedUserId); + + if (candidateIds.length === 0) { + return false; + } + + return candidateIds.includes(getRoomBanTargetId(ban)); +} + +/** Return true when any active ban entry targets the provided user. */ +export function hasRoomBanForUser( + bans: Array>, + user: BanAwareUser, + persistedUserId?: string | null +): boolean { + return bans.some((ban) => isRoomBanMatch(ban, user, persistedUserId)); +} diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts index cc13c2b..d1faf64 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -183,6 +183,14 @@ export type ChatEventType = | 'state-request' | 'screen-state' | 'role-change' + | 'room-permissions-update' + | 'server-icon-summary' + | 'server-icon-request' + | 'server-icon-full' + | 'server-icon-update' + | 'server-state-request' + | 'server-state-full' + | 'unban' | 'channels-update'; /** Optional fields depend on `type`. */ @@ -209,11 +217,17 @@ export interface ChatEvent { emoji?: string; reason?: string; settings?: RoomSettings; + permissions?: Partial; voiceState?: Partial; isScreenSharing?: boolean; role?: UserRole; + room?: Room; channels?: Channel[]; members?: RoomMember[]; + ban?: BanEntry; + bans?: BanEntry[]; + banOderId?: string; + expiresAt?: number; } export interface ServerInfo { @@ -223,12 +237,15 @@ export interface ServerInfo { topic?: string; hostName: string; ownerId?: string; + ownerName?: string; ownerPublicKey?: string; userCount: number; maxUsers: number; isPrivate: boolean; tags?: string[]; createdAt: number; + sourceId?: string; + sourceName?: string; } export interface JoinRequest { diff --git a/src/app/core/services/browser-database.service.ts b/src/app/core/services/browser-database.service.ts index 363f529..08be197 100644 --- a/src/app/core/services/browser-database.service.ts +++ b/src/app/core/services/browser-database.service.ts @@ -258,7 +258,7 @@ export class BrowserDatabaseService { async isUserBanned(userId: string, roomId: string): Promise { const activeBans = await this.getBansForRoom(roomId); - return activeBans.some((ban) => ban.oderId === userId); + return activeBans.some((ban) => ban.userId === userId || (!ban.userId && ban.oderId === userId)); } /** Persist an attachment metadata record. */ diff --git a/src/app/core/services/server-directory.service.ts b/src/app/core/services/server-directory.service.ts index 20d532f..f53910f 100644 --- a/src/app/core/services/server-directory.service.ts +++ b/src/app/core/services/server-directory.service.ts @@ -289,7 +289,7 @@ export class ServerDirectoryService { return this.searchAllEndpoints(query); } - return this.searchSingleEndpoint(query, this.buildApiBaseUrl()); + return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); } /** Retrieve the full list of public servers. */ @@ -301,7 +301,7 @@ export class ServerDirectoryService { return this.http .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) .pipe( - map((response) => this.unwrapServersResponse(response)), + map((response) => this.normalizeServerList(response, this.activeServer())), catchError((error) => { console.error('Failed to get servers:', error); return of([]); @@ -314,6 +314,7 @@ export class ServerDirectoryService { return this.http .get(`${this.buildApiBaseUrl()}/servers/${serverId}`) .pipe( + map((server) => this.normalizeServerInfo(server, this.activeServer())), catchError((error) => { console.error('Failed to get server:', error); return of(null); @@ -471,14 +472,15 @@ export class ServerDirectoryService { /** Search a single endpoint for servers matching a query. */ private searchSingleEndpoint( query: string, - apiBaseUrl: string + apiBaseUrl: string, + source?: ServerEndpoint | null ): Observable { const params = new HttpParams().set('q', query); return this.http .get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }) .pipe( - map((response) => this.unwrapServersResponse(response)), + map((response) => this.normalizeServerList(response, source)), catchError((error) => { console.error('Failed to search servers:', error); return of([]); @@ -493,19 +495,11 @@ export class ServerDirectoryService { ); if (onlineEndpoints.length === 0) { - return this.searchSingleEndpoint(query, this.buildApiBaseUrl()); + return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); } const requests = onlineEndpoints.map((endpoint) => - this.searchSingleEndpoint(query, `${endpoint.url}/api`).pipe( - map((results) => - results.map((server) => ({ - ...server, - sourceId: endpoint.id, - sourceName: endpoint.name - })) - ) - ) + this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint) ); return forkJoin(requests).pipe( @@ -524,7 +518,7 @@ export class ServerDirectoryService { return this.http .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) .pipe( - map((response) => this.unwrapServersResponse(response)), + map((response) => this.normalizeServerList(response, this.activeServer())), catchError(() => of([])) ); } @@ -533,15 +527,7 @@ export class ServerDirectoryService { this.http .get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`) .pipe( - map((response) => { - const results = this.unwrapServersResponse(response); - - return results.map((server) => ({ - ...server, - sourceId: endpoint.id, - sourceName: endpoint.name - })); - }), + map((response) => this.normalizeServerList(response, endpoint)), catchError(() => of([] as ServerInfo[])) ) ); @@ -562,6 +548,57 @@ export class ServerDirectoryService { }); } + private normalizeServerList( + response: { servers: ServerInfo[]; total: number } | ServerInfo[], + source?: ServerEndpoint | null + ): ServerInfo[] { + return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source)); + } + + private normalizeServerInfo( + server: ServerInfo | Record, + source?: ServerEndpoint | null + ): ServerInfo { + const candidate = server as Record; + const userCount = typeof candidate['userCount'] === 'number' + ? candidate['userCount'] + : (typeof candidate['currentUsers'] === 'number' ? candidate['currentUsers'] : 0); + const maxUsers = typeof candidate['maxUsers'] === 'number' ? candidate['maxUsers'] : 0; + const isPrivate = typeof candidate['isPrivate'] === 'boolean' + ? candidate['isPrivate'] + : candidate['isPrivate'] === 1; + + return { + id: typeof candidate['id'] === 'string' ? candidate['id'] : '', + name: typeof candidate['name'] === 'string' ? candidate['name'] : 'Unnamed server', + description: typeof candidate['description'] === 'string' ? candidate['description'] : undefined, + topic: typeof candidate['topic'] === 'string' ? candidate['topic'] : undefined, + hostName: + typeof candidate['hostName'] === 'string' + ? candidate['hostName'] + : (typeof candidate['sourceName'] === 'string' + ? candidate['sourceName'] + : (source?.name ?? 'Unknown API')), + ownerId: typeof candidate['ownerId'] === 'string' ? candidate['ownerId'] : undefined, + ownerName: typeof candidate['ownerName'] === 'string' ? candidate['ownerName'] : undefined, + ownerPublicKey: + typeof candidate['ownerPublicKey'] === 'string' ? candidate['ownerPublicKey'] : undefined, + userCount, + maxUsers, + isPrivate, + tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [], + createdAt: typeof candidate['createdAt'] === 'number' ? candidate['createdAt'] : Date.now(), + sourceId: + typeof candidate['sourceId'] === 'string' + ? candidate['sourceId'] + : source?.id, + sourceName: + typeof candidate['sourceName'] === 'string' + ? candidate['sourceName'] + : source?.name + }; + } + /** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */ private loadEndpoints(): void { const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY); diff --git a/src/app/features/admin/admin-panel/admin-panel.component.ts b/src/app/features/admin/admin-panel/admin-panel.component.ts index 60ee4b7..f8d500a 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.ts +++ b/src/app/features/admin/admin-panel/admin-panel.component.ts @@ -130,9 +130,9 @@ export class AdminPanelComponent { return; this.store.dispatch( - RoomsActions.updateRoom({ + RoomsActions.updateRoomSettings({ roomId: room.id, - changes: { + settings: { name: this.roomName, description: this.roomDescription, isPrivate: this.isPrivate(), @@ -168,7 +168,8 @@ export class AdminPanelComponent { /** Remove a user's ban entry. */ unbanUser(ban: BanEntry): void { - this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId })); + this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId, + oderId: ban.oderId })); } /** Show the delete-room confirmation dialog. */ @@ -203,7 +204,7 @@ export class AdminPanelComponent { return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId); } - /** Change a member's role and broadcast the update to all peers. */ + /** Change a member's role and notify connected peers. */ changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void { const roomId = this.currentRoom()?.id; @@ -218,23 +219,13 @@ export class AdminPanelComponent { }); } - /** Kick a member from the server and broadcast the action to peers. */ + /** Kick a member from the server. */ kickMember(user: User): void { this.store.dispatch(UsersActions.kickUser({ userId: user.id })); - this.webrtc.broadcastMessage({ - type: 'kick', - targetUserId: user.id, - kickedBy: this.currentUser()?.id - }); } - /** Ban a member from the server and broadcast the action to peers. */ + /** Ban a member from the server. */ banMember(user: User): void { this.store.dispatch(UsersActions.banUser({ userId: user.id })); - this.webrtc.broadcastMessage({ - type: 'ban', - targetUserId: user.id, - bannedBy: this.currentUser()?.id - }); } } diff --git a/src/app/features/chat/chat-messages/chat-messages.component.html b/src/app/features/chat/chat-messages/chat-messages.component.html index fce36de..d4d2da7 100644 --- a/src/app/features/chat/chat-messages/chat-messages.component.html +++ b/src/app/features/chat/chat-messages/chat-messages.component.html @@ -21,13 +21,41 @@
+ @if (showKlipyGifPicker()) { +
+ +
+
+ +
+
+ } + `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}` ); readonly composerBottomPadding = signal(140); + readonly klipyGifPickerAnchorRight = signal(16); readonly replyTo = signal(null); + readonly showKlipyGifPicker = signal(false); readonly lightboxAttachment = signal(null); readonly imageContextMenu = signal(null); + @HostListener('window:resize') + onWindowResize(): void { + if (this.showKlipyGifPicker()) { + this.syncKlipyGifPickerAnchor(); + } + } + handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void { this.store.dispatch( MessagesActions.sendMessage({ @@ -163,6 +179,57 @@ export class ChatMessagesComponent { this.composerBottomPadding.set(height + 20); } + toggleKlipyGifPicker(): void { + const nextState = !this.showKlipyGifPicker(); + + this.showKlipyGifPicker.set(nextState); + + if (nextState) { + requestAnimationFrame(() => this.syncKlipyGifPickerAnchor()); + } + } + + closeKlipyGifPicker(): void { + this.showKlipyGifPicker.set(false); + } + + handleKlipyGifSelected(gif: KlipyGif): void { + this.closeKlipyGifPicker(); + this.composer?.handleKlipyGifSelected(gif); + } + + private syncKlipyGifPickerAnchor(): void { + const triggerRect = this.composer?.getKlipyTriggerRect(); + + if (!triggerRect) { + this.klipyGifPickerAnchorRight.set(16); + return; + } + + const viewportWidth = window.innerWidth; + const popupWidth = this.getKlipyGifPickerWidth(viewportWidth); + const preferredRight = viewportWidth - triggerRect.right; + const minRight = 16; + const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); + + this.klipyGifPickerAnchorRight.set( + Math.min(Math.max(Math.round(preferredRight), minRight), maxRight) + ); + } + + private getKlipyGifPickerWidth(viewportWidth: number): number { + if (viewportWidth >= 1280) + return 52 * 16; + + if (viewportWidth >= 768) + return 42 * 16; + + if (viewportWidth >= 640) + return 34 * 16; + + return Math.max(0, viewportWidth - 32); + } + openLightbox(attachment: Attachment): void { if (attachment.available && attachment.objectUrl) { this.lightboxAttachment.set(attachment); diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html index f604485..4244302 100644 --- a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.html @@ -135,8 +135,9 @@
@if (klipy.isEnabled()) {
- -@if (showKlipyGifPicker() && klipy.isEnabled()) { - -} diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts index cc6efb3..f5d4756 100644 --- a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts +++ b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts @@ -22,7 +22,6 @@ import { import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service'; import { Message } from '../../../../../core/models'; import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component'; -import { KlipyGifPickerComponent } from '../../../klipy-gif-picker/klipy-gif-picker.component'; import { ChatMarkdownService } from '../../services/chat-markdown.service'; import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models'; @@ -33,7 +32,6 @@ import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model CommonModule, FormsModule, NgIcon, - KlipyGifPickerComponent, TypingIndicatorComponent ], viewProviders: [ @@ -54,19 +52,21 @@ import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { @ViewChild('messageInputRef') messageInputRef?: ElementRef; @ViewChild('composerRoot') composerRoot?: ElementRef; + @ViewChild('klipyTrigger') klipyTrigger?: ElementRef; readonly replyTo = input(null); + readonly showKlipyGifPicker = input(false); readonly messageSubmitted = output(); readonly typingStarted = output(); readonly replyCleared = output(); readonly heightChanged = output(); + readonly klipyGifPickerToggleRequested = output(); readonly klipy = inject(KlipyService); private readonly markdown = inject(ChatMarkdownService); readonly pendingKlipyGif = signal(null); - readonly showKlipyGifPicker = signal(false); readonly toolbarVisible = signal(false); readonly dragActive = signal(false); readonly inputHovered = signal(false); @@ -194,20 +194,19 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { this.setSelection(result.selectionStart, result.selectionEnd); } - openKlipyGifPicker(): void { + toggleKlipyGifPicker(): void { if (!this.klipy.isEnabled()) return; - this.showKlipyGifPicker.set(true); + this.klipyGifPickerToggleRequested.emit(); } - closeKlipyGifPicker(): void { - this.showKlipyGifPicker.set(false); + getKlipyTriggerRect(): DOMRect | null { + return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null; } handleKlipyGifSelected(gif: KlipyGif): void { this.pendingKlipyGif.set(gif); - this.closeKlipyGifPicker(); if (!this.messageContent.trim() && this.pendingFiles.length === 0) { this.sendMessage(); diff --git a/src/app/features/chat/chat-messages/services/chat-markdown.service.ts b/src/app/features/chat/chat-messages/services/chat-markdown.service.ts index 572d620..a1040af 100644 --- a/src/app/features/chat/chat-messages/services/chat-markdown.service.ts +++ b/src/app/features/chat/chat-messages/services/chat-markdown.service.ts @@ -78,7 +78,7 @@ export class ChatMarkdownService { const before = content.slice(0, start); const selected = content.slice(start, end) || 'code'; const after = content.slice(end); - const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`; + const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`; const text = `${before}${fenced}${after}`; const cursor = before.length + fenced.length; diff --git a/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.html b/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.html index 2f13ba8..977be48 100644 --- a/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.html +++ b/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.html @@ -1,144 +1,133 @@
-
- diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index f06ff28..bc54872 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -341,11 +341,6 @@ export class RoomsSidePanelComponent { if (user) { this.store.dispatch(UsersActions.kickUser({ userId: user.id })); - this.webrtc.broadcastMessage({ - type: 'kick', - targetUserId: user.id, - kickedBy: this.currentUser()?.id - }); } } diff --git a/src/app/features/server-search/server-search.component.html b/src/app/features/server-search/server-search.component.html index e16c3ae..dd06b10 100644 --- a/src/app/features/server-search/server-search.component.html +++ b/src/app/features/server-search/server-search.component.html @@ -84,15 +84,35 @@ }
@@ -137,6 +167,20 @@ } +@if (showBannedDialog()) { + +

You are banned from {{ bannedServerName() || 'this server' }}.

+
+} + @if (showCreateDialog()) {
(); + private banLookupRequestVersion = 0; searchQuery = ''; searchResults = this.store.selectSignal(selectSearchResults); isSearching = this.store.selectSignal(selectIsSearching); error = this.store.selectSignal(selectRoomsError); savedRooms = this.store.selectSignal(selectSavedRooms); + currentUser = this.store.selectSignal(selectCurrentUser); + bannedServerLookup = signal>({}); + bannedServerName = signal(''); + showBannedDialog = signal(false); // Create dialog state showCreateDialog = signal(false); @@ -79,6 +94,15 @@ export class ServerSearchComponent implements OnInit { newServerPrivate = signal(false); newServerPassword = signal(''); + constructor() { + effect(() => { + const servers = this.searchResults(); + const currentUser = this.currentUser(); + + void this.refreshBannedLookup(servers, currentUser ?? null); + }); + } + /** Initialize server search, load saved rooms, and set up debounced search. */ ngOnInit(): void { // Initial load @@ -97,7 +121,7 @@ export class ServerSearchComponent implements OnInit { } /** Join a server from the search results. Redirects to login if unauthenticated. */ - joinServer(server: ServerInfo): void { + async joinServer(server: ServerInfo): Promise { const currentUserId = localStorage.getItem('metoyou_currentUserId'); if (!currentUserId) { @@ -105,13 +129,19 @@ export class ServerSearchComponent implements OnInit { return; } + if (await this.isServerBanned(server)) { + this.bannedServerName.set(server.name); + this.showBannedDialog.set(true); + return; + } + this.store.dispatch( RoomsActions.joinRoom({ roomId: server.id, serverInfo: { name: server.name, description: server.description, - hostName: server.hostName + hostName: server.sourceName || server.hostName } }) ); @@ -160,7 +190,29 @@ export class ServerSearchComponent implements OnInit { /** Join a previously saved room by converting it to a ServerInfo payload. */ joinSavedRoom(room: Room): void { - this.joinServer(this.toServerInfo(room)); + void this.joinServer(this.toServerInfo(room)); + } + + closeBannedDialog(): void { + this.showBannedDialog.set(false); + this.bannedServerName.set(''); + } + + isServerMarkedBanned(server: ServerInfo): boolean { + return !!this.bannedServerLookup()[server.id]; + } + + getServerUserCount(server: ServerInfo): number { + const candidate = server as ServerInfo & { currentUsers?: number }; + + if (typeof server.userCount === 'number') + return server.userCount; + + return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0; + } + + getServerCapacityLabel(server: ServerInfo): string { + return server.maxUsers > 0 ? String(server.maxUsers) : '∞'; } private toServerInfo(room: Room): ServerInfo { @@ -169,13 +221,50 @@ export class ServerSearchComponent implements OnInit { name: room.name, description: room.description, hostName: room.hostId || 'Unknown', - userCount: room.userCount, + userCount: room.userCount ?? 0, maxUsers: room.maxUsers ?? 50, isPrivate: !!room.password, - createdAt: room.createdAt + createdAt: room.createdAt, + ownerId: room.hostId }; } + private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise { + const requestVersion = ++this.banLookupRequestVersion; + + if (!currentUser || servers.length === 0) { + this.bannedServerLookup.set({}); + return; + } + + const currentUserId = localStorage.getItem('metoyou_currentUserId'); + const entries = await Promise.all( + servers.map(async (server) => { + const bans = await this.db.getBansForRoom(server.id); + const isBanned = hasRoomBanForUser(bans, currentUser, currentUserId); + + return [server.id, isBanned] as const; + }) + ); + + if (requestVersion !== this.banLookupRequestVersion) + return; + + this.bannedServerLookup.set(Object.fromEntries(entries)); + } + + private async isServerBanned(server: ServerInfo): Promise { + const currentUser = this.currentUser(); + const currentUserId = localStorage.getItem('metoyou_currentUserId'); + + if (!currentUser && !currentUserId) + return false; + + const bans = await this.db.getBansForRoom(server.id); + + return hasRoomBanForUser(bans, currentUser, currentUserId); + } + private resetCreateForm(): void { this.newServerName.set(''); this.newServerDescription.set(''); diff --git a/src/app/features/servers/servers-rail.component.html b/src/app/features/servers/servers-rail.component.html index 06ae076..6988a19 100644 --- a/src/app/features/servers/servers-rail.component.html +++ b/src/app/features/servers/servers-rail.component.html @@ -14,7 +14,7 @@
- @for (room of savedRooms(); track room.id) { + @for (room of visibleSavedRooms(); track room.id) { - + @if (canChangeRoles()) { + + } + @if (canKickMembers()) { + + } + @if (canBanMembers()) { + + }
}
diff --git a/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts b/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts index d45c587..17150c7 100644 --- a/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts +++ b/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/member-ordering */ import { Component, + computed, inject, input } from '@angular/core'; @@ -10,12 +11,23 @@ import { NgIcon, provideIcons } from '@ng-icons/core'; import { Store } from '@ngrx/store'; import { lucideUserX, lucideBan } from '@ng-icons/lucide'; -import { Room, User } from '../../../../core/models/index'; +import { + Room, + RoomMember, + User, + UserRole +} from '../../../../core/models/index'; +import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { UsersActions } from '../../../../store/users/users.actions'; import { WebRTCService } from '../../../../core/services/webrtc.service'; -import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors'; +import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors'; +import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { UserAvatarComponent } from '../../../../shared'; +interface ServerMemberView extends RoomMember { + isOnline: boolean; +} + @Component({ selector: 'app-members-settings', standalone: true, @@ -41,45 +53,104 @@ export class MembersSettingsComponent { server = input(null); /** Whether the current user is admin of this server. */ isAdmin = input(false); + accessRole = input(null); currentUser = this.store.selectSignal(selectCurrentUser); - onlineUsers = this.store.selectSignal(selectOnlineUsers); + currentRoom = this.store.selectSignal(selectCurrentRoom); + usersEntities = this.store.selectSignal(selectUsersEntities); - membersFiltered(): User[] { + members = computed(() => { + const room = this.server(); const me = this.currentUser(); + const currentRoom = this.currentRoom(); + const usersEntities = this.usersEntities(); - return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId); + if (!room) + return []; + + return (room.members ?? []) + .filter((member) => member.id !== me?.id && member.oderId !== me?.oderId) + .map((member) => { + const liveUser = currentRoom?.id === room.id + ? (usersEntities[member.id] + || Object.values(usersEntities).find((user) => !!user && user.oderId === member.oderId) + || null) + : null; + + return { + ...member, + avatarUrl: liveUser?.avatarUrl || member.avatarUrl, + displayName: liveUser?.displayName || member.displayName, + isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline') + }; + }); + }); + + canChangeRoles(): boolean { + const role = this.accessRole(); + + return role === 'host' || role === 'admin'; } - changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void { - const roomId = this.server()?.id; + canKickMembers(): boolean { + const role = this.accessRole(); - this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, - role })); + return role === 'host' || role === 'admin' || role === 'moderator'; + } + + canBanMembers(): boolean { + const role = this.accessRole(); + + return role === 'host' || role === 'admin'; + } + + changeRole(member: ServerMemberView, role: 'admin' | 'moderator' | 'member'): void { + const room = this.server(); + + if (!room) + return; + + const members = (room.members ?? []).map((existingMember) => + existingMember.id === member.id || existingMember.oderId === member.oderId + ? { ...existingMember, + role } + : existingMember + ); + + this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id, + changes: { members } })); + + if (this.currentRoom()?.id === room.id) { + this.store.dispatch(UsersActions.updateUserRole({ userId: member.id, + role })); + } this.webrtcService.broadcastMessage({ type: 'role-change', - roomId, - targetUserId: user.id, + roomId: room.id, + targetUserId: member.id, role }); } - kickMember(user: User): void { - this.store.dispatch(UsersActions.kickUser({ userId: user.id })); - this.webrtcService.broadcastMessage({ - type: 'kick', - targetUserId: user.id, - kickedBy: this.currentUser()?.id - }); + kickMember(member: ServerMemberView): void { + const room = this.server(); + + if (!room) + return; + + this.store.dispatch(UsersActions.kickUser({ userId: member.id, + roomId: room.id })); } - banMember(user: User): void { - this.store.dispatch(UsersActions.banUser({ userId: user.id })); - this.webrtcService.broadcastMessage({ - type: 'ban', - targetUserId: user.id, - bannedBy: this.currentUser()?.id - }); + banMember(member: ServerMemberView): void { + const room = this.server(); + + if (!room) + return; + + this.store.dispatch(UsersActions.banUser({ userId: member.id, + roomId: room.id, + displayName: member.displayName })); } } diff --git a/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts b/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts index c3cd72c..137352d 100644 --- a/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts +++ b/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts @@ -87,9 +87,9 @@ export class ServerSettingsComponent { return; this.store.dispatch( - RoomsActions.updateRoom({ + RoomsActions.updateRoomSettings({ roomId: room.id, - changes: { + settings: { name: this.roomName, description: this.roomDescription, isPrivate: this.isPrivate(), diff --git a/src/app/features/settings/settings-modal/settings-modal.component.html b/src/app/features/settings/settings-modal/settings-modal.component.html index 9eaf456..c905e33 100644 --- a/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/src/app/features/settings/settings-modal/settings-modal.component.html @@ -57,7 +57,7 @@ } - @if (savedRooms().length > 0) { + @if (manageableRooms().length > 0) {

Server

@@ -69,13 +69,13 @@ (change)="onServerSelect($event)" > - @for (room of savedRooms(); track room.id) { + @for (room of manageableRooms(); track room.id) { }
- @if (selectedServerId() && isSelectedServerAdmin()) { + @if (selectedServerId() && canAccessSelectedServer()) { @for (page of serverPages; track page.id) {