Add access control rework
This commit is contained in:
@@ -6,23 +6,25 @@ infrastructure adapters and UI.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Domain | Purpose | Public entry point |
|
||||
|---|---|---|
|
||||
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
| Domain | Purpose | Public entry point |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
|
||||
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||
| **access-control** | Role, permission, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()` |
|
||||
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
|
||||
## Detailed docs
|
||||
|
||||
The larger domains also keep longer design notes in their own folders:
|
||||
|
||||
- [attachment/README.md](attachment/README.md)
|
||||
- [access-control/README.md](access-control/README.md)
|
||||
- [auth/README.md](auth/README.md)
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
@@ -66,12 +68,12 @@ domains/<name>/
|
||||
|
||||
## Where do I put new code?
|
||||
|
||||
| I want to… | Put it in… |
|
||||
|---|---|
|
||||
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||
| Add a top-level page or shell component | `features/` |
|
||||
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||
| I want to… | Put it in… |
|
||||
| --------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||
| Add a top-level page or shell component | `features/` |
|
||||
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||
|
||||
37
toju-app/src/app/domains/access-control/README.md
Normal file
37
toju-app/src/app/domains/access-control/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Access Control Domain
|
||||
|
||||
Role and permission rules for servers, including default system roles, role assignment normalization, permission resolution, legacy compatibility mapping, and room-level access-control hydration.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
access-control/
|
||||
├── domain/
|
||||
│ ├── access-control.models.ts MemberIdentity and RoomPermissionDefinition domain types
|
||||
│ ├── access-control.constants.ts SYSTEM_ROLE_IDS and permission metadata
|
||||
│ ├── role.rules.ts Role defaults, normalization, ordering, create/update helpers
|
||||
│ ├── role-assignment.rules.ts Assignment normalization and member-role lookups
|
||||
│ ├── permission.rules.ts Permission resolution and moderation hierarchy checks
|
||||
│ ├── room.rules.ts Legacy compatibility, room hydration, room-level normalization
|
||||
│ └── access-control.logic.ts Public barrel for domain rules
|
||||
│
|
||||
└── index.ts Domain barrel used by other layers
|
||||
```
|
||||
|
||||
## Domain rules
|
||||
|
||||
| Function | Purpose |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `normalizeRoomRoles(room.roles, room.permissions)` | Repairs missing/default roles and keeps role ordering stable |
|
||||
| `normalizeRoomRoleAssignments(...)` | Deduplicates and backfills member role assignments from legacy member role fields |
|
||||
| `normalizeChannelPermissionOverrides(...)` | Deduplicates valid channel overrides and drops invalid references |
|
||||
| `resolveRoomPermission(room, identity, permission, channelId?)` | Resolves effective permission state including overrides |
|
||||
| `canManageMember(...)` | Applies both permission checks and role hierarchy checks |
|
||||
| `canManageRole(...)` | Prevents editing roles at or above the actor's highest role |
|
||||
| `normalizeRoomAccessControl(room)` | Produces a fully hydrated room with normalized roles, assignments, overrides, and legacy compatibility fields |
|
||||
|
||||
## Layering
|
||||
|
||||
- Domain rules stay pure and only depend on `shared-kernel` contracts plus other files in this domain.
|
||||
- Renderer shells and NgRx effects should keep importing from `src/app/domains/access-control/` instead of internal files.
|
||||
- Legacy `room.permissions` booleans remain compatibility output only; normalized data lives on `roles`, `roleAssignments`, `channelPermissions`, and `slowModeInterval`.
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { RoomPermissionDefinition } from './access-control.models';
|
||||
|
||||
export const SYSTEM_ROLE_IDS = {
|
||||
everyone: 'system-everyone',
|
||||
moderator: 'system-moderator',
|
||||
admin: 'system-admin'
|
||||
} as const;
|
||||
|
||||
export const ROOM_PERMISSION_DEFINITIONS: RoomPermissionDefinition[] = [
|
||||
{
|
||||
key: 'manageServer',
|
||||
label: 'Manage Server',
|
||||
description: 'Edit server settings such as name, privacy, and limits.'
|
||||
},
|
||||
{
|
||||
key: 'manageRoles',
|
||||
label: 'Manage Roles',
|
||||
description: 'Create, edit, reorder, and assign roles.'
|
||||
},
|
||||
{
|
||||
key: 'manageChannels',
|
||||
label: 'Manage Channels',
|
||||
description: 'Create, rename, delete, and reorder channels.'
|
||||
},
|
||||
{
|
||||
key: 'manageIcon',
|
||||
label: 'Manage Icon',
|
||||
description: 'Change the server icon for all members.'
|
||||
},
|
||||
{
|
||||
key: 'kickMembers',
|
||||
label: 'Kick Members',
|
||||
description: 'Remove members from the server without banning them.'
|
||||
},
|
||||
{
|
||||
key: 'banMembers',
|
||||
label: 'Ban Members',
|
||||
description: 'Ban members from the server.'
|
||||
},
|
||||
{
|
||||
key: 'manageBans',
|
||||
label: 'Manage Bans',
|
||||
description: 'Review and revoke existing bans.'
|
||||
},
|
||||
{
|
||||
key: 'deleteMessages',
|
||||
label: 'Delete Messages',
|
||||
description: 'Delete messages sent by other members.'
|
||||
},
|
||||
{
|
||||
key: 'joinVoice',
|
||||
label: 'Join Voice',
|
||||
description: 'Join voice channels.'
|
||||
},
|
||||
{
|
||||
key: 'shareScreen',
|
||||
label: 'Share Screen',
|
||||
description: 'Start screen sharing in voice channels.'
|
||||
},
|
||||
{
|
||||
key: 'uploadFiles',
|
||||
label: 'Upload Files',
|
||||
description: 'Upload attachments in chat.'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
PermissionState,
|
||||
RoomPermissionKey,
|
||||
RoomPermissionMatrix,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
ROOM_PERMISSION_KEYS
|
||||
} from '../../../shared-kernel';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
|
||||
export function normalizeName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function compareText(firstValue: string, secondValue: string): number {
|
||||
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function uniqueStrings(values: readonly string[]): string[] {
|
||||
return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0).map((value) => value.trim())));
|
||||
}
|
||||
|
||||
export function normalizePermissionState(value: unknown): PermissionState {
|
||||
return value === 'allow' || value === 'deny' || value === 'inherit' ? value : 'inherit';
|
||||
}
|
||||
|
||||
export function normalizePermissionMatrix(matrix: RoomPermissionMatrix | undefined): RoomPermissionMatrix {
|
||||
const normalized: RoomPermissionMatrix = {};
|
||||
|
||||
for (const key of ROOM_PERMISSION_KEYS) {
|
||||
const value = normalizePermissionState(matrix?.[key]);
|
||||
|
||||
if (value !== 'inherit') {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function memberIdentityKey(identity: MemberIdentity | null | undefined): string {
|
||||
return identity?.oderId?.trim() || identity?.id?.trim() || '';
|
||||
}
|
||||
|
||||
export function matchesIdentity(identity: MemberIdentity | null | undefined, candidate: Pick<RoomRoleAssignment, 'userId' | 'oderId'>): boolean {
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(
|
||||
(identity.id && (candidate.userId === identity.id || candidate.oderId === identity.id)) ||
|
||||
(identity.oderId && (candidate.userId === identity.oderId || candidate.oderId === identity.oderId))
|
||||
);
|
||||
}
|
||||
|
||||
export function roleSortAscending(firstRole: RoomRole, secondRole: RoomRole): number {
|
||||
if (firstRole.position !== secondRole.position) {
|
||||
return firstRole.position - secondRole.position;
|
||||
}
|
||||
|
||||
return compareText(firstRole.name, secondRole.name);
|
||||
}
|
||||
|
||||
export function roleSortDescending(firstRole: RoomRole, secondRole: RoomRole): number {
|
||||
return roleSortAscending(secondRole, firstRole);
|
||||
}
|
||||
|
||||
export function permissionStateToBoolean(value: PermissionState | undefined, fallbackValue: boolean): boolean {
|
||||
if (value === 'allow') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === 'deny') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
export function getRolePermissionState(role: RoomRole | undefined, permission: RoomPermissionKey): PermissionState {
|
||||
return normalizePermissionState(role?.permissions?.[permission]);
|
||||
}
|
||||
|
||||
export function buildSystemRole(id: string, name: string, position: number, permissions: RoomPermissionMatrix, color: string): RoomRole {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
color,
|
||||
isSystem: true,
|
||||
permissions: normalizePermissionMatrix(permissions)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRoleLookup(roles: readonly RoomRole[]): Map<string, RoomRole> {
|
||||
return new Map(roles.map((role) => [role.id, role]));
|
||||
}
|
||||
|
||||
export function nextRolePosition(roles: readonly RoomRole[]): number {
|
||||
if (roles.length === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.max(...roles.map((role) => role.position)) + 100;
|
||||
}
|
||||
|
||||
export function resolveLegacyAllowState(
|
||||
value: boolean | undefined,
|
||||
currentState: PermissionState | undefined,
|
||||
disabledState: Exclude<PermissionState, 'allow'>
|
||||
): PermissionState | undefined {
|
||||
if (value === undefined) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return value ? 'allow' : disabledState;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './access-control.models';
|
||||
export * from './access-control.constants';
|
||||
export * from './role.rules';
|
||||
export * from './role-assignment.rules';
|
||||
export * from './permission.rules';
|
||||
export * from './room.rules';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
User
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export interface RoomPermissionDefinition {
|
||||
key: RoomPermissionKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type MemberIdentity = Pick<RoomMember, 'id' | 'oderId'> | Pick<User, 'id' | 'oderId'> | { id?: string; oderId?: string };
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ChannelPermissionOverride,
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomPermissionKey,
|
||||
RoomRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
getRolePermissionState,
|
||||
matchesIdentity,
|
||||
normalizePermissionState,
|
||||
roleSortAscending,
|
||||
compareText
|
||||
} from './access-control.internal';
|
||||
import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules';
|
||||
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
|
||||
|
||||
function resolveRolePermissionState(roles: readonly RoomRole[], assignedRoleIds: readonly string[], permission: RoomPermissionKey): PermissionState {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const assignedRoles = assignedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role);
|
||||
const effectiveRoles = [roleLookup.get(SYSTEM_ROLE_IDS.everyone), ...assignedRoles]
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortAscending);
|
||||
|
||||
let state: PermissionState = 'inherit';
|
||||
|
||||
for (const role of effectiveRoles) {
|
||||
const nextState = getRolePermissionState(role, permission);
|
||||
|
||||
if (nextState !== 'inherit') {
|
||||
state = nextState;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function resolveChannelOverrideState(
|
||||
overrides: readonly ChannelPermissionOverride[],
|
||||
roles: readonly RoomRole[],
|
||||
assignedRoleIds: readonly string[],
|
||||
identity: MemberIdentity,
|
||||
channelId: string,
|
||||
permission: RoomPermissionKey,
|
||||
baseState: PermissionState
|
||||
): PermissionState {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
|
||||
let state = baseState;
|
||||
|
||||
const everyoneOverride = overrides.find(
|
||||
(override) =>
|
||||
override.channelId === channelId &&
|
||||
override.targetType === 'role' &&
|
||||
override.targetId === SYSTEM_ROLE_IDS.everyone &&
|
||||
override.permission === permission
|
||||
);
|
||||
|
||||
if (everyoneOverride?.value && everyoneOverride.value !== 'inherit') {
|
||||
state = everyoneOverride.value;
|
||||
}
|
||||
|
||||
const orderedAssignedRoles = assignedRoleIds
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortAscending);
|
||||
|
||||
for (const role of orderedAssignedRoles) {
|
||||
const override = overrides.find(
|
||||
(candidateOverride) =>
|
||||
candidateOverride.channelId === channelId &&
|
||||
candidateOverride.targetType === 'role' &&
|
||||
candidateOverride.targetId === role.id &&
|
||||
candidateOverride.permission === permission
|
||||
);
|
||||
|
||||
if (override?.value && override.value !== 'inherit') {
|
||||
state = override.value;
|
||||
}
|
||||
}
|
||||
|
||||
const userOverride = overrides.find(
|
||||
(override) =>
|
||||
override.channelId === channelId &&
|
||||
override.targetType === 'user' &&
|
||||
override.permission === permission &&
|
||||
(override.targetId === identity.id || override.targetId === identity.oderId)
|
||||
);
|
||||
|
||||
if (userOverride?.value && userOverride.value !== 'inherit') {
|
||||
state = userOverride.value;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function normalizeChannelPermissionOverrides(
|
||||
overrides: readonly ChannelPermissionOverride[] | undefined,
|
||||
roles: readonly RoomRole[]
|
||||
): ChannelPermissionOverride[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id));
|
||||
const normalizedByKey = new Map<string, ChannelPermissionOverride>();
|
||||
|
||||
for (const override of overrides ?? []) {
|
||||
if (!override || typeof override !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channelId = typeof override.channelId === 'string' ? override.channelId.trim() : '';
|
||||
const targetId = typeof override.targetId === 'string' ? override.targetId.trim() : '';
|
||||
const targetType = override.targetType === 'role' || override.targetType === 'user' ? override.targetType : null;
|
||||
const permission = override.permission;
|
||||
const value = normalizePermissionState(override.value);
|
||||
|
||||
if (!channelId || !targetId || !targetType || !permission || value === 'inherit') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetType === 'role' && !validRoleIds.has(targetId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByKey.set(`${channelId}:${targetType}:${targetId}:${permission}`, {
|
||||
channelId,
|
||||
targetType,
|
||||
targetId,
|
||||
permission,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(normalizedByKey.values()).sort((firstOverride, secondOverride) => {
|
||||
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
|
||||
|
||||
if (channelCompare !== 0) {
|
||||
return channelCompare;
|
||||
}
|
||||
|
||||
if (firstOverride.targetType !== secondOverride.targetType) {
|
||||
return compareText(firstOverride.targetType, secondOverride.targetType);
|
||||
}
|
||||
|
||||
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
|
||||
|
||||
if (targetCompare !== 0) {
|
||||
return targetCompare;
|
||||
}
|
||||
|
||||
return compareText(firstOverride.permission, secondOverride.permission);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveRoomPermission(
|
||||
room: Room,
|
||||
identity: MemberIdentity | null | undefined,
|
||||
permission: RoomPermissionKey,
|
||||
channelId?: string
|
||||
): boolean {
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
|
||||
const roleState = resolveRolePermissionState(roles, assignedRoleIds, permission);
|
||||
const channelState = channelId
|
||||
? resolveChannelOverrideState(
|
||||
normalizeChannelPermissionOverrides(room.channelPermissions, roles),
|
||||
roles,
|
||||
assignedRoleIds,
|
||||
identity,
|
||||
channelId,
|
||||
permission,
|
||||
roleState
|
||||
)
|
||||
: roleState;
|
||||
|
||||
return channelState === 'allow';
|
||||
}
|
||||
|
||||
export function canManageMember(
|
||||
room: Room,
|
||||
actor: MemberIdentity | null | undefined,
|
||||
target: MemberIdentity | null | undefined,
|
||||
permission: 'kickMembers' | 'banMembers' | 'manageRoles'
|
||||
): boolean {
|
||||
if (!actor || !target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isActorOwner = room.hostId === actor.id || room.hostId === actor.oderId;
|
||||
const isTargetOwner = room.hostId === target.id || room.hostId === target.oderId;
|
||||
const isSameIdentity = matchesIdentity(actor, {
|
||||
userId: target.id || target.oderId || '',
|
||||
oderId: target.oderId
|
||||
});
|
||||
|
||||
if (isSameIdentity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTargetOwner && !isActorOwner) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isActorOwner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveRoomPermission(room, actor, permission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actorRole = getHighestAssignedRole(room, actor);
|
||||
const targetRole = getHighestAssignedRole(room, target);
|
||||
|
||||
return (actorRole?.position ?? 0) > (targetRole?.position ?? 0);
|
||||
}
|
||||
|
||||
export function canManageRole(room: Room, actor: MemberIdentity | null | undefined, roleId: string): boolean {
|
||||
if (!actor || !roleId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.hostId === actor.id || room.hostId === actor.oderId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolveRoomPermission(room, actor, 'manageRoles')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetRole = getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), roleId);
|
||||
const actorRole = getHighestAssignedRole(room, actor);
|
||||
|
||||
if (!targetRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (actorRole?.position ?? 0) > targetRole.position;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomRole,
|
||||
RoomRoleAssignment
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
compareText,
|
||||
memberIdentityKey,
|
||||
matchesIdentity,
|
||||
roleSortDescending,
|
||||
uniqueStrings
|
||||
} from './access-control.internal';
|
||||
import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
|
||||
|
||||
function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] {
|
||||
return [...assignments].sort((firstAssignment, secondAssignment) =>
|
||||
compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLegacyMemberRoleIds(member: RoomMember, validRoleIds: Set<string>): string[] {
|
||||
if (Array.isArray(member.roleIds) && member.roleIds.length > 0) {
|
||||
return uniqueStrings(member.roleIds).filter((roleId) => validRoleIds.has(roleId));
|
||||
}
|
||||
|
||||
if (member.role === 'admin') {
|
||||
return [SYSTEM_ROLE_IDS.admin];
|
||||
}
|
||||
|
||||
if (member.role === 'moderator') {
|
||||
return [SYSTEM_ROLE_IDS.moderator];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeRoomRoleAssignments(
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
members: readonly RoomMember[] | undefined,
|
||||
roles: readonly RoomRole[]
|
||||
): RoomRoleAssignment[] {
|
||||
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
|
||||
const normalizedByUserKey = new Map<string, RoomRoleAssignment>();
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
if (!assignment || typeof assignment !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const userId = typeof assignment.userId === 'string' ? assignment.userId.trim() : '';
|
||||
const oderId = typeof assignment.oderId === 'string' ? assignment.oderId.trim() : undefined;
|
||||
const key = oderId || userId;
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleIds = uniqueStrings(assignment.roleIds ?? []).filter((roleId) => validRoleIds.has(roleId));
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByUserKey.set(key, {
|
||||
userId: userId || key,
|
||||
oderId,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedByUserKey.size > 0) {
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
for (const member of members ?? []) {
|
||||
const key = memberIdentityKey(member);
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleIds = resolveLegacyMemberRoleIds(member, validRoleIds);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedByUserKey.set(key, {
|
||||
userId: member.id || key,
|
||||
oderId: member.oderId || undefined,
|
||||
roleIds
|
||||
});
|
||||
}
|
||||
|
||||
return sortAssignments(Array.from(normalizedByUserKey.values()));
|
||||
}
|
||||
|
||||
export function getAssignedRoleIds(assignments: readonly RoomRoleAssignment[] | undefined, identity: MemberIdentity | null | undefined): string[] {
|
||||
const assignment = (assignments ?? []).find((candidateAssignment) => matchesIdentity(identity, candidateAssignment));
|
||||
|
||||
return uniqueStrings(assignment?.roleIds ?? []);
|
||||
}
|
||||
|
||||
export function getDisplayRoleName(room: Room, member: MemberIdentity | null | undefined): string {
|
||||
if (!member) {
|
||||
return 'Member';
|
||||
}
|
||||
|
||||
if (room.hostId === member.id || room.hostId === member.oderId) {
|
||||
return 'Owner';
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const assignedRoles = getAssignedRoleIds(room.roleAssignments, member)
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
|
||||
return assignedRoles[0]?.name || '@everyone';
|
||||
}
|
||||
|
||||
export function getAssignedRoles(room: Room, identity: MemberIdentity | null | undefined): RoomRole[] {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
|
||||
return getAssignedRoleIds(room.roleAssignments, identity)
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
}
|
||||
|
||||
export function getHighestAssignedRole(room: Room, identity: MemberIdentity | null | undefined): RoomRole | null {
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getAssignedRoles(room, identity)[0] ?? getRoomRoleById(normalizeRoomRoles(room.roles, room.permissions), SYSTEM_ROLE_IDS.everyone) ?? null;
|
||||
}
|
||||
|
||||
export function setRoleAssignmentsForMember(
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
member: MemberIdentity,
|
||||
roleIds: readonly string[]
|
||||
): RoomRoleAssignment[] {
|
||||
const nextAssignments = new Map<string, RoomRoleAssignment>();
|
||||
const memberKey = memberIdentityKey(member);
|
||||
|
||||
for (const assignment of assignments ?? []) {
|
||||
const key = memberIdentityKey({
|
||||
id: assignment.userId,
|
||||
oderId: assignment.oderId
|
||||
});
|
||||
|
||||
if (!key || key === memberKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextAssignments.set(key, {
|
||||
userId: assignment.userId,
|
||||
oderId: assignment.oderId,
|
||||
roleIds: uniqueStrings(assignment.roleIds)
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRoleIds = uniqueStrings(roleIds);
|
||||
|
||||
if (memberKey && normalizedRoleIds.length > 0) {
|
||||
nextAssignments.set(memberKey, {
|
||||
userId: member.id || member.oderId || memberKey,
|
||||
oderId: member.oderId || undefined,
|
||||
roleIds: normalizedRoleIds
|
||||
});
|
||||
}
|
||||
|
||||
return sortAssignments(Array.from(nextAssignments.values()));
|
||||
}
|
||||
|
||||
export function removeRoleFromAssignments(assignments: readonly RoomRoleAssignment[] | undefined, roleId: string): RoomRoleAssignment[] {
|
||||
return (assignments ?? [])
|
||||
.map((assignment) => ({
|
||||
...assignment,
|
||||
roleIds: assignment.roleIds.filter((candidateRoleId) => candidateRoleId !== roleId)
|
||||
}))
|
||||
.filter((assignment) => assignment.roleIds.length > 0);
|
||||
}
|
||||
|
||||
export function getRoleIdsForMember(room: Room, member: MemberIdentity | null | undefined): string[] {
|
||||
return getAssignedRoleIds(
|
||||
normalizeRoomRoleAssignments(room.roleAssignments, room.members, normalizeRoomRoles(room.roles, room.permissions)),
|
||||
member
|
||||
);
|
||||
}
|
||||
171
toju-app/src/app/domains/access-control/domain/role.rules.ts
Normal file
171
toju-app/src/app/domains/access-control/domain/role.rules.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
RoomPermissionMatrix,
|
||||
RoomPermissions,
|
||||
RoomRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
buildSystemRole,
|
||||
nextRolePosition,
|
||||
normalizeName,
|
||||
normalizePermissionMatrix,
|
||||
roleSortAscending,
|
||||
roleSortDescending
|
||||
} from './access-control.internal';
|
||||
|
||||
const ROLE_COLORS = {
|
||||
everyone: '#6b7280',
|
||||
moderator: '#10b981',
|
||||
admin: '#60a5fa'
|
||||
} as const;
|
||||
|
||||
function resolveNormalizedRolePosition(
|
||||
position: unknown,
|
||||
fallbackPosition: number | undefined,
|
||||
existingRoles: readonly RoomRole[],
|
||||
defaultRoles: readonly RoomRole[]
|
||||
): number {
|
||||
if (typeof position === 'number' && Number.isFinite(position)) {
|
||||
return position;
|
||||
}
|
||||
|
||||
if (typeof fallbackPosition === 'number') {
|
||||
return fallbackPosition;
|
||||
}
|
||||
|
||||
return nextRolePosition(existingRoles.length > 0 ? existingRoles : defaultRoles);
|
||||
}
|
||||
|
||||
function normalizeRoomRoleEntry(
|
||||
role: RoomRole | null | undefined,
|
||||
defaultsById: Map<string, RoomRole>,
|
||||
existingRoles: readonly RoomRole[],
|
||||
defaultRoles: readonly RoomRole[]
|
||||
): RoomRole | null {
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = typeof role.id === 'string' ? role.id.trim() : '';
|
||||
const fallbackRole = defaultsById.get(id);
|
||||
const name = normalizeName(typeof role.name === 'string' ? role.name : (fallbackRole?.name ?? 'Role'));
|
||||
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
position: resolveNormalizedRolePosition(role.position, fallbackRole?.position, existingRoles, defaultRoles),
|
||||
color: typeof role.color === 'string' && role.color.trim() ? role.color.trim() : fallbackRole?.color,
|
||||
isSystem: typeof role.isSystem === 'boolean' ? role.isSystem : fallbackRole?.isSystem,
|
||||
permissions: normalizePermissionMatrix(role.permissions ?? fallbackRole?.permissions)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultRoomRoles(legacyPermissions?: RoomPermissions): RoomRole[] {
|
||||
const everyonePermissions: RoomPermissionMatrix = {
|
||||
joinVoice: legacyPermissions?.allowVoice === false ? 'deny' : 'allow',
|
||||
shareScreen: legacyPermissions?.allowScreenShare === false ? 'deny' : 'allow',
|
||||
uploadFiles: legacyPermissions?.allowFileUploads === false ? 'deny' : 'allow'
|
||||
};
|
||||
const moderatorPermissions: RoomPermissionMatrix = {
|
||||
kickMembers: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: legacyPermissions?.moderatorsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions?.moderatorsManageIcon ? 'allow' : 'inherit'
|
||||
};
|
||||
const adminPermissions: RoomPermissionMatrix = {
|
||||
kickMembers: 'allow',
|
||||
banMembers: 'allow',
|
||||
manageBans: 'allow',
|
||||
deleteMessages: 'allow',
|
||||
manageChannels: legacyPermissions?.adminsManageRooms ? 'allow' : 'inherit',
|
||||
manageIcon: legacyPermissions?.adminsManageIcon ? 'allow' : 'inherit'
|
||||
};
|
||||
|
||||
return [
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.everyone, '@everyone', 0, everyonePermissions, ROLE_COLORS.everyone),
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.moderator, 'Moderator', 200, moderatorPermissions, ROLE_COLORS.moderator),
|
||||
buildSystemRole(SYSTEM_ROLE_IDS.admin, 'Admin', 300, adminPermissions, ROLE_COLORS.admin)
|
||||
];
|
||||
}
|
||||
|
||||
export function sortRolesForDisplay(roles: readonly RoomRole[]): RoomRole[] {
|
||||
return [...roles].sort(roleSortDescending);
|
||||
}
|
||||
|
||||
export function normalizeRoomRoles(roles: readonly RoomRole[] | undefined, legacyPermissions?: RoomPermissions): RoomRole[] {
|
||||
const defaultRoles = buildDefaultRoomRoles(legacyPermissions);
|
||||
const defaultsById = buildRoleLookup(defaultRoles);
|
||||
const normalizedById = new Map<string, RoomRole>();
|
||||
|
||||
for (const role of roles ?? []) {
|
||||
const normalizedRole = normalizeRoomRoleEntry(role, defaultsById, Array.from(normalizedById.values()), defaultRoles);
|
||||
|
||||
if (normalizedRole) {
|
||||
normalizedById.set(normalizedRole.id, normalizedRole);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [roleId, role] of defaultsById) {
|
||||
if (!normalizedById.has(roleId)) {
|
||||
normalizedById.set(roleId, role);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(normalizedById.values()).sort(roleSortAscending);
|
||||
}
|
||||
|
||||
export function getRoomRoleById(roles: readonly RoomRole[] | undefined, roleId: string): RoomRole | undefined {
|
||||
return (roles ?? []).find((role) => role.id === roleId);
|
||||
}
|
||||
|
||||
export function createCustomRoomRole(name: string, roles: readonly RoomRole[]): RoomRole {
|
||||
const normalizedName = normalizeName(name) || 'New Role';
|
||||
|
||||
return {
|
||||
id: `role-${crypto.randomUUID()}`,
|
||||
name: normalizedName,
|
||||
position: nextRolePosition(roles),
|
||||
permissions: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderRoles(roles: readonly RoomRole[], orderedRoleIds: readonly string[]): RoomRole[] {
|
||||
const roleLookup = buildRoleLookup(roles);
|
||||
const systemRoles = roles.filter((role) => role.isSystem);
|
||||
const customRoles = orderedRoleIds.map((roleId) => roleLookup.get(roleId)).filter((role): role is RoomRole => !!role && !role.isSystem);
|
||||
const remainingCustomRoles = roles.filter((role) => !role.isSystem && !orderedRoleIds.includes(role.id));
|
||||
const orderedRoles = sortRolesForDisplay(systemRoles).concat(customRoles)
|
||||
.concat(sortRolesForDisplay(remainingCustomRoles));
|
||||
|
||||
return orderedRoles
|
||||
.map((role, index) => ({
|
||||
...role,
|
||||
position: (orderedRoles.length - index - 1) * 100
|
||||
}))
|
||||
.sort(roleSortAscending);
|
||||
}
|
||||
|
||||
export function withUpdatedRole(roles: readonly RoomRole[], roleId: string, updates: Partial<RoomRole>): RoomRole[] {
|
||||
return normalizeRoomRoles(
|
||||
roles.map((role) => {
|
||||
if (role.id !== roleId) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
...updates,
|
||||
permissions: normalizePermissionMatrix(updates.permissions ?? role.permissions)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function findAssignableRoles(roles: readonly RoomRole[]): RoomRole[] {
|
||||
return sortRolesForDisplay(roles).filter((role) => role.id !== SYSTEM_ROLE_IDS.everyone);
|
||||
}
|
||||
209
toju-app/src/app/domains/access-control/domain/room.rules.ts
Normal file
209
toju-app/src/app/domains/access-control/domain/room.rules.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
PermissionState,
|
||||
Room,
|
||||
RoomMember,
|
||||
RoomPermissionKey,
|
||||
RoomPermissions,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
UserRole
|
||||
} from '../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from './access-control.constants';
|
||||
import {
|
||||
getRolePermissionState,
|
||||
permissionStateToBoolean,
|
||||
resolveLegacyAllowState
|
||||
} from './access-control.internal';
|
||||
import type { MemberIdentity } from './access-control.models';
|
||||
import {
|
||||
getAssignedRoleIds,
|
||||
normalizeRoomRoleAssignments,
|
||||
removeRoleFromAssignments
|
||||
} from './role-assignment.rules';
|
||||
import {
|
||||
getRoomRoleById,
|
||||
normalizeRoomRoles,
|
||||
withUpdatedRole
|
||||
} from './role.rules';
|
||||
import { normalizeChannelPermissionOverrides, resolveRoomPermission } from './permission.rules';
|
||||
|
||||
function applyRolePermissionChanges(
|
||||
roles: readonly RoomRole[],
|
||||
role: RoomRole | null | undefined,
|
||||
changes: Partial<Record<RoomPermissionKey, PermissionState | undefined>>
|
||||
): RoomRole[] {
|
||||
if (!role) {
|
||||
return [...roles];
|
||||
}
|
||||
|
||||
return withUpdatedRole(roles, role.id, {
|
||||
permissions: {
|
||||
...role.permissions,
|
||||
...changes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getEveryoneLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
joinVoice: resolveLegacyAllowState(permissions.allowVoice, role.permissions?.joinVoice, 'deny'),
|
||||
shareScreen: resolveLegacyAllowState(permissions.allowScreenShare, role.permissions?.shareScreen, 'deny'),
|
||||
uploadFiles: resolveLegacyAllowState(permissions.allowFileUploads, role.permissions?.uploadFiles, 'deny')
|
||||
};
|
||||
}
|
||||
|
||||
function getModeratorLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
manageChannels: resolveLegacyAllowState(permissions.moderatorsManageRooms, role.permissions?.manageChannels, 'inherit'),
|
||||
manageIcon: resolveLegacyAllowState(permissions.moderatorsManageIcon, role.permissions?.manageIcon, 'inherit')
|
||||
};
|
||||
}
|
||||
|
||||
function getAdminLegacyPermissionChanges(
|
||||
role: RoomRole,
|
||||
permissions: Partial<RoomPermissions>
|
||||
): Partial<Record<RoomPermissionKey, PermissionState | undefined>> {
|
||||
return {
|
||||
manageChannels: resolveLegacyAllowState(permissions.adminsManageRooms, role.permissions?.manageChannels, 'inherit'),
|
||||
manageIcon: resolveLegacyAllowState(permissions.adminsManageIcon, role.permissions?.manageIcon, 'inherit')
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLegacyRole(room: Room, identity: MemberIdentity | null | undefined): UserRole {
|
||||
if (!identity) {
|
||||
return 'member';
|
||||
}
|
||||
|
||||
if (room.hostId === identity.id || room.hostId === identity.oderId) {
|
||||
return 'host';
|
||||
}
|
||||
|
||||
const assignedRoleIds = getAssignedRoleIds(room.roleAssignments, identity);
|
||||
|
||||
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.admin)) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (assignedRoleIds.includes(SYSTEM_ROLE_IDS.moderator)) {
|
||||
return 'moderator';
|
||||
}
|
||||
|
||||
if (
|
||||
resolveRoomPermission(room, identity, 'manageRoles') ||
|
||||
resolveRoomPermission(room, identity, 'banMembers') ||
|
||||
resolveRoomPermission(room, identity, 'manageBans') ||
|
||||
resolveRoomPermission(room, identity, 'manageServer')
|
||||
) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (
|
||||
resolveRoomPermission(room, identity, 'kickMembers') ||
|
||||
resolveRoomPermission(room, identity, 'deleteMessages') ||
|
||||
resolveRoomPermission(room, identity, 'manageChannels') ||
|
||||
resolveRoomPermission(room, identity, 'manageIcon')
|
||||
) {
|
||||
return 'moderator';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
export function deriveLegacyRoomPermissions(room: Pick<Room, 'roles' | 'permissions' | 'slowModeInterval'>): RoomPermissions {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
|
||||
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
|
||||
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
|
||||
|
||||
return {
|
||||
allowVoice: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'joinVoice'), true),
|
||||
allowScreenShare: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'shareScreen'), true),
|
||||
allowFileUploads: permissionStateToBoolean(getRolePermissionState(everyoneRole, 'uploadFiles'), true),
|
||||
adminsManageRooms: getRolePermissionState(adminRole, 'manageChannels') === 'allow',
|
||||
moderatorsManageRooms: getRolePermissionState(moderatorRole, 'manageChannels') === 'allow',
|
||||
adminsManageIcon: getRolePermissionState(adminRole, 'manageIcon') === 'allow',
|
||||
moderatorsManageIcon: getRolePermissionState(moderatorRole, 'manageIcon') === 'allow',
|
||||
slowModeInterval: room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export function withLegacyRoomPermissions(room: Room, permissions: Partial<RoomPermissions>): Room {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const everyoneRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.everyone);
|
||||
const moderatorRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.moderator);
|
||||
const adminRole = getRoomRoleById(roles, SYSTEM_ROLE_IDS.admin);
|
||||
|
||||
let nextRoles = applyRolePermissionChanges(roles, everyoneRole, everyoneRole ? getEveryoneLegacyPermissionChanges(everyoneRole, permissions) : {});
|
||||
|
||||
nextRoles = applyRolePermissionChanges(
|
||||
nextRoles,
|
||||
moderatorRole,
|
||||
moderatorRole ? getModeratorLegacyPermissionChanges(moderatorRole, permissions) : {}
|
||||
);
|
||||
|
||||
nextRoles = applyRolePermissionChanges(nextRoles, adminRole, adminRole ? getAdminLegacyPermissionChanges(adminRole, permissions) : {});
|
||||
|
||||
return normalizeRoomAccessControl({
|
||||
...room,
|
||||
roles: nextRoles,
|
||||
slowModeInterval: permissions.slowModeInterval ?? room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
export function hydrateRoomMembers(room: Room): RoomMember[] {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
|
||||
|
||||
return (room.members ?? []).map((member) => {
|
||||
const roleIds = getAssignedRoleIds(roleAssignments, member);
|
||||
const hydratedRoom: Room = {
|
||||
...room,
|
||||
roles,
|
||||
roleAssignments
|
||||
};
|
||||
|
||||
return {
|
||||
...member,
|
||||
roleIds,
|
||||
role: resolveLegacyRole(hydratedRoom, member)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRoomAccessControl(room: Room): Room {
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
const roleAssignments = normalizeRoomRoleAssignments(room.roleAssignments, room.members, roles);
|
||||
const channelPermissions = normalizeChannelPermissionOverrides(room.channelPermissions, roles);
|
||||
const slowModeInterval = room.slowModeInterval ?? room.permissions?.slowModeInterval ?? 0;
|
||||
const nextRoom: Room = {
|
||||
...room,
|
||||
roles,
|
||||
roleAssignments,
|
||||
channelPermissions,
|
||||
slowModeInterval
|
||||
};
|
||||
|
||||
nextRoom.permissions = deriveLegacyRoomPermissions(nextRoom);
|
||||
nextRoom.members = hydrateRoomMembers(nextRoom);
|
||||
|
||||
return nextRoom;
|
||||
}
|
||||
|
||||
export function removeRole(
|
||||
roles: readonly RoomRole[],
|
||||
assignments: readonly RoomRoleAssignment[] | undefined,
|
||||
roleId: string
|
||||
): { roles: RoomRole[]; roleAssignments: RoomRoleAssignment[] } {
|
||||
const nextRoles = roles.filter((role) => role.id !== roleId || role.isSystem);
|
||||
|
||||
return {
|
||||
roles: normalizeRoomRoles(nextRoles),
|
||||
roleAssignments: removeRoleFromAssignments(assignments, roleId)
|
||||
};
|
||||
}
|
||||
6
toju-app/src/app/domains/access-control/index.ts
Normal file
6
toju-app/src/app/domains/access-control/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './domain/access-control.models';
|
||||
export * from './domain/access-control.constants';
|
||||
export * from './domain/role.rules';
|
||||
export * from './domain/role-assignment.rules';
|
||||
export * from './domain/permission.rules';
|
||||
export * from './domain/room.rules';
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||
<div #composerRoot>
|
||||
@if (replyTo()) {
|
||||
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
||||
|
||||
@@ -77,43 +77,17 @@
|
||||
@if (msg.isDeleted) {
|
||||
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
|
||||
} @else {
|
||||
<div class="chat-markdown mt-1 break-words">
|
||||
<remark
|
||||
[markdown]="msg.content"
|
||||
[processor]="$any(remarkProcessor)"
|
||||
>
|
||||
<ng-template
|
||||
[remarkTemplate]="'code'"
|
||||
let-node
|
||||
>
|
||||
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
|
||||
<remark-mermaid [code]="getMermaidCode(node.value)" />
|
||||
} @else {
|
||||
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'image'"
|
||||
let-node
|
||||
>
|
||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<img
|
||||
[appChatImageProxyFallback]="node.url"
|
||||
[alt]="node.alt || 'Shared image'"
|
||||
class="block max-h-80 max-w-full w-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
@if (isKlipyMediaUrl(node.url)) {
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</remark>
|
||||
</div>
|
||||
@if (requiresRichMarkdown(msg.content)) {
|
||||
@defer {
|
||||
<div class="chat-markdown mt-1 break-words">
|
||||
<app-chat-message-markdown [content]="msg.content" />
|
||||
</div>
|
||||
} @placeholder {
|
||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||
}
|
||||
|
||||
@if (attachmentsList.length > 0) {
|
||||
<div class="mt-2 space-y-2">
|
||||
|
||||
@@ -24,11 +24,6 @@ import {
|
||||
lucideTrash2,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { MermaidComponent, RemarkModule } from 'ngx-remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentFacade,
|
||||
@@ -41,7 +36,7 @@ import {
|
||||
ChatVideoPlayerComponent,
|
||||
UserAvatarComponent
|
||||
} from '../../../../../../shared';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
import { ChatMessageMarkdownComponent } from './chat-message-markdown.component';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
@@ -60,28 +55,7 @@ const COMMON_EMOJIS = [
|
||||
'🔥',
|
||||
'👀'
|
||||
];
|
||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cs: 'csharp',
|
||||
html: 'markup',
|
||||
js: 'javascript',
|
||||
md: 'markdown',
|
||||
plain: 'none',
|
||||
plaintext: 'none',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
svg: 'markup',
|
||||
text: 'none',
|
||||
ts: 'typescript',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
zsh: 'bash'
|
||||
};
|
||||
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
||||
const REMARK_PROCESSOR = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks);
|
||||
const RICH_MARKDOWN_PATTERN = /(^|\n)(#{1,6}\s|>\s|[-*+]\s|\d+\.\s|```|~~~)|!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|_[^_\n]+_|(?:^|\n)\|.+\|/m;
|
||||
|
||||
interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
isAudio: boolean;
|
||||
@@ -101,9 +75,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
NgIcon,
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
RemarkModule,
|
||||
MermaidComponent,
|
||||
ChatImageProxyFallbackDirective,
|
||||
ChatMessageMarkdownComponent,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -136,7 +108,6 @@ export class ChatMessageItemComponent {
|
||||
readonly repliedMessage = input<Message | undefined>();
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
readonly isAdmin = input(false);
|
||||
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
@@ -320,23 +291,8 @@ export class ChatMessageItemComponent {
|
||||
);
|
||||
}
|
||||
|
||||
getMermaidCode(code?: string): string {
|
||||
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
|
||||
}
|
||||
|
||||
isKlipyMediaUrl(url?: string): boolean {
|
||||
if (!url)
|
||||
return false;
|
||||
|
||||
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
|
||||
}
|
||||
|
||||
isMermaidCodeBlock(lang?: string): boolean {
|
||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||
}
|
||||
|
||||
getCodeBlockClass(lang?: string): string {
|
||||
return `language-${this.normalizeCodeLanguage(lang)}`;
|
||||
requiresRichMarkdown(content: string): boolean {
|
||||
return RICH_MARKDOWN_PATTERN.test(content);
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
@@ -468,15 +424,6 @@ export class ChatMessageItemComponent {
|
||||
this.downloadRequested.emit(attachment);
|
||||
}
|
||||
|
||||
private normalizeCodeLanguage(lang?: string): string {
|
||||
const normalized = (lang || '').trim().toLowerCase();
|
||||
|
||||
if (!normalized)
|
||||
return 'none';
|
||||
|
||||
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||
const isVideo = this.isVideoAttachment(attachment);
|
||||
const isAudio = this.isAudioAttachment(attachment);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<remark
|
||||
[markdown]="content()"
|
||||
[processor]="$any(remarkProcessor)"
|
||||
>
|
||||
<ng-template
|
||||
[remarkTemplate]="'code'"
|
||||
let-node
|
||||
>
|
||||
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
|
||||
<remark-mermaid [code]="getMermaidCode(node.value)" />
|
||||
} @else {
|
||||
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'image'"
|
||||
let-node
|
||||
>
|
||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<img
|
||||
[appChatImageProxyFallback]="node.url"
|
||||
[alt]="node.alt || 'Shared image'"
|
||||
class="block max-h-80 max-w-full w-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
@if (isKlipyMediaUrl(node.url)) {
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</remark>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
import { MermaidComponent, RemarkModule } from 'ngx-remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||
|
||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cs: 'csharp',
|
||||
html: 'markup',
|
||||
js: 'javascript',
|
||||
md: 'markdown',
|
||||
plain: 'none',
|
||||
plaintext: 'none',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
svg: 'markup',
|
||||
text: 'none',
|
||||
ts: 'typescript',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
zsh: 'bash'
|
||||
};
|
||||
const KLIPY_MEDIA_URL_PATTERN = /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i;
|
||||
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
||||
const REMARK_PROCESSOR = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks);
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-markdown',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RemarkModule,
|
||||
MermaidComponent,
|
||||
ChatImageProxyFallbackDirective
|
||||
],
|
||||
templateUrl: './chat-message-markdown.component.html'
|
||||
})
|
||||
export class ChatMessageMarkdownComponent {
|
||||
readonly content = input.required<string>();
|
||||
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||
|
||||
getMermaidCode(code?: string): string {
|
||||
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
|
||||
}
|
||||
|
||||
isKlipyMediaUrl(url?: string): boolean {
|
||||
if (!url)
|
||||
return false;
|
||||
|
||||
return KLIPY_MEDIA_URL_PATTERN.test(url);
|
||||
}
|
||||
|
||||
isMermaidCodeBlock(lang?: string): boolean {
|
||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||
}
|
||||
|
||||
getCodeBlockClass(lang?: string): string {
|
||||
return `language-${this.normalizeCodeLanguage(lang)}`;
|
||||
}
|
||||
|
||||
private normalizeCodeLanguage(lang?: string): string {
|
||||
const normalized = (lang || '').trim().toLowerCase();
|
||||
|
||||
if (!normalized)
|
||||
return 'none';
|
||||
|
||||
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
|
||||
readonly dateSeparatorLabels = computed(() => {
|
||||
const labels = new Map<number, string>();
|
||||
|
||||
let previousDayKey: string | null = null;
|
||||
|
||||
this.messages().forEach((message, index) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||
<div
|
||||
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
|
||||
role="dialog"
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
selectCurrentRoom
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -16,10 +20,7 @@ import { NotificationsFacade } from '../../application/notifications.facade';
|
||||
@Component({
|
||||
selector: 'app-notifications-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon
|
||||
],
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideBell,
|
||||
@@ -49,10 +50,12 @@ export class NotificationsSettingsComponent {
|
||||
|
||||
return channels.length > 0
|
||||
? channels
|
||||
: [{ id: 'general',
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
position: 0 }];
|
||||
: [
|
||||
{ id: 'general',
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
position: 0 }
|
||||
];
|
||||
}
|
||||
|
||||
onNotificationsEnabledChange(event: Event): void {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Channel } from '../../../shared-kernel';
|
||||
import type {
|
||||
Channel,
|
||||
ChannelPermissionOverride,
|
||||
RoomRole,
|
||||
RoomRoleAssignment
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
|
||||
|
||||
@@ -17,6 +22,10 @@ export interface ServerInfo {
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
channels?: Channel[];
|
||||
slowModeInterval?: number;
|
||||
roles?: RoomRole[];
|
||||
roleAssignments?: RoomRoleAssignment[];
|
||||
channelPermissions?: ChannelPermissionOverride[];
|
||||
createdAt: number;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import {
|
||||
ChannelPermissionOverride,
|
||||
type Channel,
|
||||
ROOM_PERMISSION_KEYS,
|
||||
RoomRole,
|
||||
RoomRoleAssignment,
|
||||
User
|
||||
} from '../../../shared-kernel';
|
||||
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
|
||||
@@ -48,10 +52,12 @@ export class ServerDirectoryApiService {
|
||||
return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null;
|
||||
}
|
||||
|
||||
return this.endpointState.activeServer()
|
||||
?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible')
|
||||
?? this.endpointState.servers()[0]
|
||||
?? null;
|
||||
return (
|
||||
this.endpointState.activeServer() ??
|
||||
this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible') ??
|
||||
this.endpointState.servers()[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
searchServers(query: string, shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
|
||||
@@ -67,41 +73,35 @@ export class ServerDirectoryApiService {
|
||||
return this.getAllServersFromAllEndpoints();
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
|
||||
return this.http
|
||||
.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
|
||||
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
return this.http.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateServer(
|
||||
@@ -113,157 +113,111 @@ export class ServerDirectoryApiService {
|
||||
},
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
return this.http.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
return this.http.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
|
||||
return this.http
|
||||
.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
return this.http.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
requestJoin(
|
||||
request: ServerJoinAccessRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerJoinAccessResponse> {
|
||||
return this.http
|
||||
.post<ServerJoinAccessResponse>(
|
||||
`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`,
|
||||
request
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
requestJoin(request: ServerJoinAccessRequest, selector?: ServerSourceSelector): Observable<ServerJoinAccessResponse> {
|
||||
return this.http.post<ServerJoinAccessResponse>(`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createInvite(
|
||||
serverId: string,
|
||||
request: CreateServerInviteRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to create invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
createInvite(serverId: string, request: CreateServerInviteRequest, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
||||
return this.http.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to create invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
return this.http.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
kickServerMember(
|
||||
serverId: string,
|
||||
request: KickServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to kick server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
kickServerMember(serverId: string, request: KickServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to kick server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
banServerMember(
|
||||
serverId: string,
|
||||
request: BanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to ban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
banServerMember(serverId: string, request: BanServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to ban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unbanServerMember(
|
||||
serverId: string,
|
||||
request: UnbanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
unbanServerMember(serverId: string, request: UnbanServerMemberRequest, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
return this.http.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
return this.http
|
||||
.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
return this.http.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count }).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
sendHeartbeat(serverId: string): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
return this.http.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {}).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
|
||||
@@ -274,71 +228,51 @@ export class ServerDirectoryApiService {
|
||||
return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl();
|
||||
}
|
||||
|
||||
private unwrapServersResponse(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[]
|
||||
): ServerInfo[] {
|
||||
return Array.isArray(response)
|
||||
? response
|
||||
: (response.servers ?? []);
|
||||
private unwrapServersResponse(response: { servers: ServerInfo[]; total: number } | ServerInfo[]): ServerInfo[] {
|
||||
return Array.isArray(response) ? response : (response.servers ?? []);
|
||||
}
|
||||
|
||||
private searchSingleEndpoint(
|
||||
query: string,
|
||||
apiBaseUrl: string,
|
||||
source?: ServerEndpoint | null
|
||||
): Observable<ServerInfo[]> {
|
||||
private searchSingleEndpoint(query: string, apiBaseUrl: string, source?: ServerEndpoint | null): Observable<ServerInfo[]> {
|
||||
const params = new HttpParams().set('q', query);
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, source)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }).pipe(
|
||||
map((response) => this.normalizeServerList(response, source)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
||||
}
|
||||
|
||||
return forkJoin(
|
||||
onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))
|
||||
).pipe(
|
||||
return forkJoin(onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers))
|
||||
);
|
||||
}
|
||||
|
||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter((endpoint) => endpoint.status !== 'offline');
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`).pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
return forkJoin(
|
||||
onlineEndpoints.map((endpoint) =>
|
||||
this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, endpoint)),
|
||||
catchError(() => of([] as ServerInfo[]))
|
||||
)
|
||||
this.http.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`).pipe(
|
||||
map((response) => this.normalizeServerList(response, endpoint)),
|
||||
catchError(() => of([] as ServerInfo[]))
|
||||
)
|
||||
)
|
||||
).pipe(map((resultArrays) => resultArrays.flat()));
|
||||
}
|
||||
@@ -356,17 +290,11 @@ export class ServerDirectoryApiService {
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeServerList(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo[] {
|
||||
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<string, unknown>,
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo {
|
||||
private normalizeServerInfo(server: ServerInfo | Record<string, unknown>, source?: ServerEndpoint | null): ServerInfo {
|
||||
const candidate = server as Record<string, unknown>;
|
||||
const sourceName = this.getStringValue(candidate['sourceName']);
|
||||
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
|
||||
@@ -384,14 +312,16 @@ export class ServerDirectoryApiService {
|
||||
maxUsers: this.getNumberValue(candidate['maxUsers']),
|
||||
hasPassword: this.getBooleanValue(candidate['hasPassword']),
|
||||
isPrivate: this.getBooleanValue(candidate['isPrivate']),
|
||||
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
|
||||
tags: Array.isArray(candidate['tags']) ? (candidate['tags'] as string[]) : [],
|
||||
channels: this.getChannelsValue(candidate['channels']),
|
||||
slowModeInterval: this.getNumberValue(candidate['slowModeInterval'], 0),
|
||||
roles: this.getRolesValue(candidate['roles']),
|
||||
roleAssignments: this.getRoleAssignmentsValue(candidate['roleAssignments']),
|
||||
channelPermissions: this.getChannelPermissionOverridesValue(candidate['channelPermissions']),
|
||||
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
|
||||
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
|
||||
sourceName: sourceName ?? source?.name,
|
||||
sourceUrl: sourceUrl
|
||||
? this.endpointState.sanitiseUrl(sourceUrl)
|
||||
: (source ? this.endpointState.sanitiseUrl(source.url) : undefined)
|
||||
sourceUrl: sourceUrl ? this.endpointState.sanitiseUrl(sourceUrl) : source ? this.endpointState.sanitiseUrl(source.url) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -430,6 +360,123 @@ export class ServerDirectoryApiService {
|
||||
.filter((channel): channel is Channel => !!channel);
|
||||
}
|
||||
|
||||
private getRolesValue(value: unknown): RoomRole[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((role): role is Record<string, unknown> => !!role && typeof role === 'object')
|
||||
.flatMap((role, index) => {
|
||||
const id = this.getStringValue(role['id']);
|
||||
const name = this.getStringValue(role['name']);
|
||||
const position = this.getNumberValue(role['position'], index * 100);
|
||||
|
||||
if (!id || !name) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedRole: RoomRole = {
|
||||
id,
|
||||
name,
|
||||
position,
|
||||
permissions: this.getPermissionMatrixValue(role['permissions'])
|
||||
};
|
||||
const color = this.getStringValue(role['color']);
|
||||
const isSystem = typeof role['isSystem'] === 'boolean' ? role['isSystem'] : this.getBooleanValue(role['isSystem']);
|
||||
|
||||
if (color) {
|
||||
normalizedRole.color = color;
|
||||
}
|
||||
|
||||
if (typeof isSystem === 'boolean') {
|
||||
normalizedRole.isSystem = isSystem;
|
||||
}
|
||||
|
||||
return [normalizedRole];
|
||||
});
|
||||
}
|
||||
|
||||
private getRoleAssignmentsValue(value: unknown): RoomRoleAssignment[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((assignment): assignment is Record<string, unknown> => !!assignment && typeof assignment === 'object')
|
||||
.flatMap((assignment) => {
|
||||
const userId = this.getStringValue(assignment['userId']);
|
||||
const roleIds = Array.isArray(assignment['roleIds'])
|
||||
? assignment['roleIds'].filter((roleId): roleId is string => typeof roleId === 'string')
|
||||
: [];
|
||||
|
||||
if (!userId || roleIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedAssignment: RoomRoleAssignment = {
|
||||
userId,
|
||||
roleIds
|
||||
};
|
||||
const oderId = this.getStringValue(assignment['oderId']);
|
||||
|
||||
if (oderId) {
|
||||
normalizedAssignment.oderId = oderId;
|
||||
}
|
||||
|
||||
return [normalizedAssignment];
|
||||
});
|
||||
}
|
||||
|
||||
private getChannelPermissionOverridesValue(value: unknown): ChannelPermissionOverride[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((override): override is Record<string, unknown> => !!override && typeof override === 'object')
|
||||
.map((override) => {
|
||||
const channelId = this.getStringValue(override['channelId']);
|
||||
const targetId = this.getStringValue(override['targetId']);
|
||||
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : undefined;
|
||||
const permission = ROOM_PERMISSION_KEYS.find((candidatePermission) => candidatePermission === override['permission']);
|
||||
const valueState =
|
||||
override['value'] === 'allow' || override['value'] === 'deny' || override['value'] === 'inherit' ? override['value'] : undefined;
|
||||
|
||||
if (!channelId || !targetId || !targetType || !permission || !valueState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
targetId,
|
||||
targetType,
|
||||
permission,
|
||||
value: valueState
|
||||
} satisfies ChannelPermissionOverride;
|
||||
})
|
||||
.filter((override): override is ChannelPermissionOverride => !!override);
|
||||
}
|
||||
|
||||
private getPermissionMatrixValue(value: unknown): RoomRole['permissions'] {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matrix = value as Record<string, unknown>;
|
||||
const normalized = ROOM_PERMISSION_KEYS.reduce<NonNullable<RoomRole['permissions']>>((nextMatrix, permission) => {
|
||||
const permissionValue = matrix[permission];
|
||||
|
||||
if (permissionValue === 'allow' || permissionValue === 'deny' || permissionValue === 'inherit') {
|
||||
nextMatrix[permission] = permissionValue;
|
||||
}
|
||||
|
||||
return nextMatrix;
|
||||
}, {});
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
private getChannelTypeValue(value: unknown): Channel['type'] | undefined {
|
||||
return value === 'text' || value === 'voice' ? value : undefined;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
SettingsModalService,
|
||||
type SettingsPage
|
||||
} from '../../../core/services/settings-modal.service';
|
||||
Injectable,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { SettingsModalService, type SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { ThemeRegistryService } from './theme-registry.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -13,7 +14,7 @@ export class ElementPickerService {
|
||||
private readonly modal = inject(SettingsModalService);
|
||||
private readonly registry = inject(ThemeRegistryService);
|
||||
|
||||
private removeListeners: Array<() => void> = [];
|
||||
private removeListeners: (() => void)[] = [];
|
||||
private resumePage: SettingsPage | null = null;
|
||||
private shouldRestoreModalOnCancel = true;
|
||||
|
||||
@@ -69,7 +70,6 @@ export class ElementPickerService {
|
||||
|
||||
this.hoveredKey.set(key);
|
||||
};
|
||||
|
||||
const onClick = (event: Event) => {
|
||||
const key = this.resolveThemeKeyFromTarget(event.target);
|
||||
|
||||
@@ -86,7 +86,6 @@ export class ElementPickerService {
|
||||
|
||||
this.completePick(key);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: Event) => {
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
|
||||
@@ -129,4 +128,4 @@ export class ElementPickerService {
|
||||
? key
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeContainerKey,
|
||||
@@ -55,4 +59,4 @@ export class LayoutSyncService {
|
||||
}
|
||||
}, true, `${containerKey} restored to its default layout.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import type { SavedThemeSummary } from '../domain/theme.models';
|
||||
import { ThemeLibraryStorageService } from '../infrastructure/theme-library.storage';
|
||||
import { ThemeService } from './theme.service';
|
||||
@@ -173,4 +178,4 @@ export class ThemeLibraryService {
|
||||
|
||||
this.selectedFileName.set(entries[0]?.fileName ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeLayoutContainerDefinition,
|
||||
ThemeRegistryEntry
|
||||
} from '../domain/theme.models';
|
||||
import { ThemeLayoutContainerDefinition, ThemeRegistryEntry } from '../domain/theme.models';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY,
|
||||
@@ -85,4 +82,4 @@ export class ThemeRegistryService {
|
||||
|
||||
this.mountedCounts.set(nextCounts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ThemeAnimationDefinition,
|
||||
@@ -13,12 +18,8 @@ import {
|
||||
createDefaultThemeDocument,
|
||||
isLegacyDefaultThemeDocument
|
||||
} from '../domain/theme.defaults';
|
||||
import {
|
||||
createAnimationStarterDefinition
|
||||
} from '../domain/theme.schema';
|
||||
import {
|
||||
findThemeLayoutContainer
|
||||
} from '../domain/theme.registry';
|
||||
import { createAnimationStarterDefinition } from '../domain/theme.schema';
|
||||
import { findThemeLayoutContainer } from '../domain/theme.registry';
|
||||
import { validateThemeDocument } from '../domain/theme.validation';
|
||||
import {
|
||||
loadThemeStorageSnapshot,
|
||||
@@ -280,28 +281,71 @@ export class ThemeService {
|
||||
styles['backgroundImage'] = backgroundLayers.join(', ');
|
||||
}
|
||||
|
||||
if (elementTheme.width) styles['width'] = elementTheme.width;
|
||||
if (elementTheme.height) styles['height'] = elementTheme.height;
|
||||
if (elementTheme.minWidth) styles['minWidth'] = elementTheme.minWidth;
|
||||
if (elementTheme.minHeight) styles['minHeight'] = elementTheme.minHeight;
|
||||
if (elementTheme.maxWidth) styles['maxWidth'] = elementTheme.maxWidth;
|
||||
if (elementTheme.maxHeight) styles['maxHeight'] = elementTheme.maxHeight;
|
||||
if (elementTheme.position) styles['position'] = elementTheme.position;
|
||||
if (elementTheme.top) styles['top'] = elementTheme.top;
|
||||
if (elementTheme.right) styles['right'] = elementTheme.right;
|
||||
if (elementTheme.bottom) styles['bottom'] = elementTheme.bottom;
|
||||
if (elementTheme.left) styles['left'] = elementTheme.left;
|
||||
if (elementTheme.padding) styles['padding'] = elementTheme.padding;
|
||||
if (elementTheme.margin) styles['margin'] = elementTheme.margin;
|
||||
if (elementTheme.border) styles['border'] = elementTheme.border;
|
||||
if (elementTheme.borderRadius) styles['borderRadius'] = elementTheme.borderRadius;
|
||||
if (elementTheme.backgroundColor) styles['backgroundColor'] = elementTheme.backgroundColor;
|
||||
if (elementTheme.color) styles['color'] = elementTheme.color;
|
||||
if (elementTheme.backgroundSize) styles['backgroundSize'] = elementTheme.backgroundSize;
|
||||
if (elementTheme.backgroundPosition) styles['backgroundPosition'] = elementTheme.backgroundPosition;
|
||||
if (elementTheme.backgroundRepeat) styles['backgroundRepeat'] = elementTheme.backgroundRepeat;
|
||||
if (elementTheme.boxShadow) styles['boxShadow'] = elementTheme.boxShadow;
|
||||
if (elementTheme.backdropFilter) styles['backdropFilter'] = elementTheme.backdropFilter;
|
||||
if (elementTheme.width)
|
||||
styles['width'] = elementTheme.width;
|
||||
|
||||
if (elementTheme.height)
|
||||
styles['height'] = elementTheme.height;
|
||||
|
||||
if (elementTheme.minWidth)
|
||||
styles['minWidth'] = elementTheme.minWidth;
|
||||
|
||||
if (elementTheme.minHeight)
|
||||
styles['minHeight'] = elementTheme.minHeight;
|
||||
|
||||
if (elementTheme.maxWidth)
|
||||
styles['maxWidth'] = elementTheme.maxWidth;
|
||||
|
||||
if (elementTheme.maxHeight)
|
||||
styles['maxHeight'] = elementTheme.maxHeight;
|
||||
|
||||
if (elementTheme.position)
|
||||
styles['position'] = elementTheme.position;
|
||||
|
||||
if (elementTheme.top)
|
||||
styles['top'] = elementTheme.top;
|
||||
|
||||
if (elementTheme.right)
|
||||
styles['right'] = elementTheme.right;
|
||||
|
||||
if (elementTheme.bottom)
|
||||
styles['bottom'] = elementTheme.bottom;
|
||||
|
||||
if (elementTheme.left)
|
||||
styles['left'] = elementTheme.left;
|
||||
|
||||
if (elementTheme.padding)
|
||||
styles['padding'] = elementTheme.padding;
|
||||
|
||||
if (elementTheme.margin)
|
||||
styles['margin'] = elementTheme.margin;
|
||||
|
||||
if (elementTheme.border)
|
||||
styles['border'] = elementTheme.border;
|
||||
|
||||
if (elementTheme.borderRadius)
|
||||
styles['borderRadius'] = elementTheme.borderRadius;
|
||||
|
||||
if (elementTheme.backgroundColor)
|
||||
styles['backgroundColor'] = elementTheme.backgroundColor;
|
||||
|
||||
if (elementTheme.color)
|
||||
styles['color'] = elementTheme.color;
|
||||
|
||||
if (elementTheme.backgroundSize)
|
||||
styles['backgroundSize'] = elementTheme.backgroundSize;
|
||||
|
||||
if (elementTheme.backgroundPosition)
|
||||
styles['backgroundPosition'] = elementTheme.backgroundPosition;
|
||||
|
||||
if (elementTheme.backgroundRepeat)
|
||||
styles['backgroundRepeat'] = elementTheme.backgroundRepeat;
|
||||
|
||||
if (elementTheme.boxShadow)
|
||||
styles['boxShadow'] = elementTheme.boxShadow;
|
||||
|
||||
if (elementTheme.backdropFilter)
|
||||
styles['backdropFilter'] = elementTheme.backdropFilter;
|
||||
|
||||
if (typeof elementTheme.opacity === 'number') {
|
||||
styles['opacity'] = `${elementTheme.opacity}`;
|
||||
@@ -512,4 +556,4 @@ export class ThemeService {
|
||||
this.statusTimeoutId = null;
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { DEFAULT_THEME_DOCUMENT } from './theme.defaults';
|
||||
import {
|
||||
THEME_LAYOUT_CONTAINERS,
|
||||
THEME_REGISTRY
|
||||
} from './theme.registry';
|
||||
import { THEME_LAYOUT_CONTAINERS, THEME_REGISTRY } from './theme.registry';
|
||||
import {
|
||||
THEME_ANIMATION_FIELDS,
|
||||
THEME_ELEMENT_STYLE_FIELDS,
|
||||
@@ -168,4 +165,4 @@ export const THEME_LLM_GUIDE = [
|
||||
'- Keep layout edits plausible for the declared container grid size.',
|
||||
'- If a field is unsupported, omit it instead of guessing.',
|
||||
'- If a section does not need changes, leave it empty rather than filling it with noise.'
|
||||
].join('\n');
|
||||
].join('\n');
|
||||
|
||||
@@ -27,6 +27,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: 1,
|
||||
h: 1 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: (appShell?.columns ?? 20) - 1,
|
||||
h: 1 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: 4,
|
||||
h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -62,6 +65,7 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: 12,
|
||||
h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -87,16 +91,19 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
color: 'hsl(var(--foreground))',
|
||||
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.18), transparent 34%), linear-gradient(180deg, rgba(5, 8, 15, 0.98), rgba(9, 12, 20, 1))'
|
||||
};
|
||||
|
||||
elements['serversRail'] = {
|
||||
backgroundColor: 'hsl(var(--rail-background) / 0.96)',
|
||||
gradient: 'linear-gradient(180deg, rgba(10, 14, 25, 0.92), rgba(6, 9, 16, 0.98))',
|
||||
boxShadow: 'inset -1px 0 0 hsl(var(--border) / 0.82), 18px 0 38px rgba(0, 0, 0, 0.22)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['appWorkspace'] = {
|
||||
backgroundColor: 'hsl(var(--workspace-background))',
|
||||
gradient: 'radial-gradient(circle at top right, hsl(var(--surface-highlight-alt) / 0.14), transparent 30%), linear-gradient(180deg, rgba(9, 12, 21, 0.96), rgba(7, 10, 18, 1))'
|
||||
};
|
||||
|
||||
elements['titleBar'] = {
|
||||
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -104,6 +111,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'inset 0 -1px 0 hsl(var(--border) / 0.78), 0 12px 28px rgba(0, 0, 0, 0.18)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomChannelsPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.9)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -113,6 +121,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomMainPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -122,6 +131,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'var(--theme-effect-panel-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomMembersPanel'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -131,6 +141,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['chatRoomEmptyState'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
@@ -139,6 +150,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.08), transparent 45%)',
|
||||
boxShadow: 'var(--theme-effect-soft-shadow)'
|
||||
};
|
||||
|
||||
elements['voiceWorkspace'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background) / 0.74)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -148,6 +160,7 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
boxShadow: 'var(--theme-effect-panel-shadow)',
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['floatingVoiceControls'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)',
|
||||
color: 'hsl(var(--foreground))',
|
||||
@@ -238,4 +251,4 @@ export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
||||
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
|
||||
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
|
||||
|
||||
@@ -130,4 +130,4 @@ export interface ThemeSchemaField<T extends string = string> {
|
||||
type: 'string' | 'number' | 'object';
|
||||
example: string | number;
|
||||
examples: readonly (string | number)[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
ThemeLayoutContainerDefinition,
|
||||
ThemeRegistryEntry
|
||||
} from './theme.models';
|
||||
import { ThemeLayoutContainerDefinition, ThemeRegistryEntry } from './theme.models';
|
||||
|
||||
export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[] = [
|
||||
{
|
||||
@@ -158,4 +155,4 @@ export function getPickerVisibleThemeKeys(): string[] {
|
||||
return THEME_REGISTRY
|
||||
.filter((entry) => entry.pickerVisible)
|
||||
.map((entry) => entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,28 +96,44 @@ export const THEME_GRID_FIELDS: readonly ThemeSchemaField[] = [
|
||||
description: 'Horizontal grid start column, zero-based.',
|
||||
type: 'number',
|
||||
example: 4,
|
||||
examples: [0, 1, 4]
|
||||
examples: [
|
||||
0,
|
||||
1,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'y',
|
||||
description: 'Vertical grid start row, zero-based.',
|
||||
type: 'number',
|
||||
example: 0,
|
||||
examples: [0, 1, 6]
|
||||
examples: [
|
||||
0,
|
||||
1,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'w',
|
||||
description: 'Grid width in columns.',
|
||||
type: 'number',
|
||||
example: 12,
|
||||
examples: [1, 4, 12]
|
||||
examples: [
|
||||
1,
|
||||
4,
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'h',
|
||||
description: 'Grid height in rows.',
|
||||
type: 'number',
|
||||
example: 12,
|
||||
examples: [1, 4, 12]
|
||||
examples: [
|
||||
1,
|
||||
4,
|
||||
12
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -127,14 +143,22 @@ export const THEME_ANIMATION_FIELDS: readonly ThemeSchemaField[] = [
|
||||
description: 'Animation duration.',
|
||||
type: 'string',
|
||||
example: '240ms',
|
||||
examples: ['200ms', '240ms', '600ms']
|
||||
examples: [
|
||||
'200ms',
|
||||
'240ms',
|
||||
'600ms'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'easing',
|
||||
description: 'Animation easing function.',
|
||||
type: 'string',
|
||||
example: 'ease-out',
|
||||
examples: ['ease', 'ease-out', 'cubic-bezier(0.16, 1, 0.3, 1)']
|
||||
examples: [
|
||||
'ease',
|
||||
'ease-out',
|
||||
'cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'delay',
|
||||
@@ -155,14 +179,23 @@ export const THEME_ANIMATION_FIELDS: readonly ThemeSchemaField[] = [
|
||||
description: 'Animation fill behavior after running.',
|
||||
type: 'string',
|
||||
example: 'both',
|
||||
examples: ['none', 'forwards', 'backwards', 'both']
|
||||
examples: [
|
||||
'none',
|
||||
'forwards',
|
||||
'backwards',
|
||||
'both'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'direction',
|
||||
description: 'Animation direction.',
|
||||
type: 'string',
|
||||
example: 'normal',
|
||||
examples: ['normal', 'reverse', 'alternate']
|
||||
examples: [
|
||||
'normal',
|
||||
'reverse',
|
||||
'alternate'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'keyframes',
|
||||
@@ -179,14 +212,22 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
description: 'CSS width applied to the selected element host.',
|
||||
type: 'string',
|
||||
example: '280px',
|
||||
examples: ['280px', '20rem', 'min(24rem, 30vw)']
|
||||
examples: [
|
||||
'280px',
|
||||
'20rem',
|
||||
'min(24rem, 30vw)'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'height',
|
||||
description: 'CSS height applied to the selected element host.',
|
||||
type: 'string',
|
||||
example: '100%',
|
||||
examples: ['100%', '22rem', 'calc(100vh - 4rem)']
|
||||
examples: [
|
||||
'100%',
|
||||
'22rem',
|
||||
'calc(100vh - 4rem)'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'minWidth',
|
||||
@@ -221,56 +262,89 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
description: 'CSS positioning mode for the host element.',
|
||||
type: 'string',
|
||||
example: 'relative',
|
||||
examples: ['static', 'relative', 'absolute', 'sticky']
|
||||
examples: [
|
||||
'static',
|
||||
'relative',
|
||||
'absolute',
|
||||
'sticky'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'top',
|
||||
description: 'CSS top inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'2rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'right',
|
||||
description: 'CSS right inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'2rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'bottom',
|
||||
description: 'CSS bottom inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'2rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'left',
|
||||
description: 'CSS left inset used with positioned elements.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['0', '12px', '2rem']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'2rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'opacity',
|
||||
description: 'Element opacity between 0 and 1.',
|
||||
type: 'number',
|
||||
example: 0.96,
|
||||
examples: [0.72, 0.88, 1]
|
||||
examples: [
|
||||
0.72,
|
||||
0.88,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'padding',
|
||||
description: 'CSS padding shorthand for internal spacing.',
|
||||
type: 'string',
|
||||
example: '12px',
|
||||
examples: ['12px', '12px 16px', '1rem 1.25rem']
|
||||
examples: [
|
||||
'12px',
|
||||
'12px 16px',
|
||||
'1rem 1.25rem'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
description: 'CSS margin shorthand for external spacing.',
|
||||
type: 'string',
|
||||
example: '0',
|
||||
examples: ['0', '12px', '0 0 12px']
|
||||
examples: [
|
||||
'0',
|
||||
'12px',
|
||||
'0 0 12px'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'border',
|
||||
@@ -284,7 +358,11 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
description: 'CSS border radius shorthand.',
|
||||
type: 'string',
|
||||
example: '16px',
|
||||
examples: ['12px', '16px', '999px']
|
||||
examples: [
|
||||
'12px',
|
||||
'16px',
|
||||
'999px'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'backgroundColor',
|
||||
@@ -312,21 +390,33 @@ export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementS
|
||||
description: 'CSS background-size value.',
|
||||
type: 'string',
|
||||
example: 'cover',
|
||||
examples: ['cover', 'contain', 'auto 100%']
|
||||
examples: [
|
||||
'cover',
|
||||
'contain',
|
||||
'auto 100%'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'backgroundPosition',
|
||||
description: 'CSS background-position value.',
|
||||
type: 'string',
|
||||
example: 'center',
|
||||
examples: ['center', 'top left', '50% 20%']
|
||||
examples: [
|
||||
'center',
|
||||
'top left',
|
||||
'50% 20%'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'backgroundRepeat',
|
||||
description: 'CSS background-repeat value.',
|
||||
type: 'string',
|
||||
example: 'no-repeat',
|
||||
examples: ['no-repeat', 'repeat', 'repeat-x']
|
||||
examples: [
|
||||
'no-repeat',
|
||||
'repeat',
|
||||
'repeat-x'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'gradient',
|
||||
@@ -429,4 +519,4 @@ export function createAnimationStarterDefinition(): ThemeDocument['animations'][
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,58 @@ import {
|
||||
getLayoutEditableThemeKeys
|
||||
} from './theme.registry';
|
||||
|
||||
const TOP_LEVEL_KEYS = ['meta', 'tokens', 'layout', 'elements', 'animations'] as const;
|
||||
const META_KEYS = ['name', 'version', 'description'] as const;
|
||||
const TOKEN_GROUP_KEYS = ['colors', 'spacing', 'radii', 'effects'] as const;
|
||||
const TOP_LEVEL_KEYS = [
|
||||
'meta',
|
||||
'tokens',
|
||||
'layout',
|
||||
'elements',
|
||||
'animations'
|
||||
] as const;
|
||||
const META_KEYS = [
|
||||
'name',
|
||||
'version',
|
||||
'description'
|
||||
] as const;
|
||||
const TOKEN_GROUP_KEYS = [
|
||||
'colors',
|
||||
'spacing',
|
||||
'radii',
|
||||
'effects'
|
||||
] as const;
|
||||
const LAYOUT_ENTRY_KEYS = ['container', 'grid'] as const;
|
||||
const GRID_KEYS = ['x', 'y', 'w', 'h'] as const;
|
||||
const ANIMATION_KEYS = ['duration', 'easing', 'delay', 'iterationCount', 'fillMode', 'direction', 'keyframes'] as const;
|
||||
const POSITION_VALUES = ['static', 'relative', 'absolute', 'sticky'] as const;
|
||||
const FILL_MODE_VALUES = ['none', 'forwards', 'backwards', 'both'] as const;
|
||||
const DIRECTION_VALUES = ['normal', 'reverse', 'alternate', 'alternate-reverse'] as const;
|
||||
const GRID_KEYS = [
|
||||
'x',
|
||||
'y',
|
||||
'w',
|
||||
'h'
|
||||
] as const;
|
||||
const ANIMATION_KEYS = [
|
||||
'duration',
|
||||
'easing',
|
||||
'delay',
|
||||
'iterationCount',
|
||||
'fillMode',
|
||||
'direction',
|
||||
'keyframes'
|
||||
] as const;
|
||||
const POSITION_VALUES = [
|
||||
'static',
|
||||
'relative',
|
||||
'absolute',
|
||||
'sticky'
|
||||
] as const;
|
||||
const FILL_MODE_VALUES = [
|
||||
'none',
|
||||
'forwards',
|
||||
'backwards',
|
||||
'both'
|
||||
] as const;
|
||||
const DIRECTION_VALUES = [
|
||||
'normal',
|
||||
'reverse',
|
||||
'alternate',
|
||||
'alternate-reverse'
|
||||
] as const;
|
||||
const SAFE_LINK_PROTOCOLS = ['http:', 'https:'] as const;
|
||||
const SAFE_CLASS_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
||||
const KEYFRAME_STEP_PATTERN = /^(from|to|(?:\d|[1-9]\d|100)%)$/;
|
||||
@@ -185,6 +228,7 @@ function validateElementStyles(value: unknown, path: string, errors: string[]):
|
||||
errors.push(`${path}.${key} must be a valid absolute URL.`);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -192,6 +236,7 @@ function validateElementStyles(value: unknown, path: string, errors: string[]):
|
||||
if (validateString(fieldValue, `${path}.${key}`, errors) && !SAFE_CLASS_PATTERN.test(fieldValue)) {
|
||||
errors.push(`${path}.${key} must be a safe CSS class token.`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -449,4 +494,4 @@ export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
||||
errors: [],
|
||||
value: normaliseThemeDocument(input as Partial<ThemeDocument>)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,4 +132,4 @@ export class ThemeGridEditorComponent {
|
||||
private clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
insert: nextValue
|
||||
}
|
||||
});
|
||||
|
||||
this.isApplyingExternalValue = false;
|
||||
});
|
||||
|
||||
@@ -203,6 +204,7 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
selection: EditorSelection.range(selectionStart, selectionEnd),
|
||||
effects: EditorView.scrollIntoView(selectionStart, { y: 'center' })
|
||||
});
|
||||
|
||||
this.editorView.focus();
|
||||
}
|
||||
|
||||
@@ -242,4 +244,4 @@ export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class ThemeSettingsComponent {
|
||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||
readonly layoutContainers = this.layoutSync.containers();
|
||||
readonly themeEntries = this.registry.entries();
|
||||
readonly workspaceTabs: ReadonlyArray<{ key: ThemeStudioWorkspace; label: string; description: string }> = [
|
||||
readonly workspaceTabs: readonly { key: ThemeStudioWorkspace; label: string; description: string }[] = [
|
||||
{
|
||||
key: 'editor',
|
||||
label: 'JSON Editor',
|
||||
@@ -129,7 +129,8 @@ export class ThemeSettingsComponent {
|
||||
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
|
||||
});
|
||||
readonly filteredEntries = computed(() => {
|
||||
const query = this.explorerQuery().trim().toLowerCase();
|
||||
const query = this.explorerQuery().trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return this.mountedEntries();
|
||||
@@ -470,4 +471,4 @@ export class ThemeSettingsComponent {
|
||||
|
||||
return text.indexOf(`"${key}"`, sectionIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
HostListener,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
input,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
|
||||
import { ExternalLinkService } from '../../../core/platform';
|
||||
@@ -24,7 +25,7 @@ function looksLikeImageReference(value: string): boolean {
|
||||
selector: '[appThemeNode]',
|
||||
standalone: true
|
||||
})
|
||||
export class ThemeNodeDirective {
|
||||
export class ThemeNodeDirective implements OnDestroy {
|
||||
readonly themeKey = input.required<string>({ alias: 'appThemeNode' });
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
@@ -245,4 +246,4 @@ export class ThemeNodeDirective {
|
||||
iconTarget.style.backgroundImage = 'none';
|
||||
iconTarget.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,4 +55,4 @@ export class ThemePickerOverlayComponent {
|
||||
cancel(): void {
|
||||
this.picker.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@ export * from './domain/theme.schema';
|
||||
export * from './domain/theme.validation';
|
||||
|
||||
export { ThemeNodeDirective } from './feature/theme-node.directive';
|
||||
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
|
||||
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
|
||||
|
||||
@@ -203,7 +203,7 @@ export class ThemeLibraryStorageService {
|
||||
isValid: true,
|
||||
modifiedAt: file.modifiedAt,
|
||||
themeName: result.value.meta.name,
|
||||
version: result.value.meta.version,
|
||||
version: result.value.meta.version
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -218,4 +218,4 @@ export class ThemeLibraryStorageService {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
STORAGE_KEY_THEME_ACTIVE,
|
||||
STORAGE_KEY_THEME_DRAFT
|
||||
} from '../../../core/constants';
|
||||
import { STORAGE_KEY_THEME_ACTIVE, STORAGE_KEY_THEME_DRAFT } from '../../../core/constants';
|
||||
|
||||
export interface ThemeStorageSnapshot {
|
||||
activeText: string | null;
|
||||
@@ -41,4 +38,4 @@ export function saveActiveThemeText(text: string): void {
|
||||
|
||||
export function saveDraftThemeText(text: string): void {
|
||||
writeStoredThemeText(STORAGE_KEY_THEME_DRAFT, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@ import { Store } from '@ngrx/store';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants';
|
||||
import { ScreenShareFacade } from '../../../domains/screen-share';
|
||||
import { User } from '../../../shared-kernel';
|
||||
import {
|
||||
selectAllUsers,
|
||||
selectCurrentUser
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { VoiceConnectionFacade } from './voice-connection.facade';
|
||||
|
||||
export interface PlaybackOptions {
|
||||
|
||||
@@ -282,6 +282,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateCameraState({
|
||||
userId: user.id,
|
||||
|
||||
Reference in New Issue
Block a user