mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 09:06:07 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa1b21d32 | ||
|
|
981875c495 | ||
|
|
c90ed27194 | ||
|
|
8f46ea0f2c | ||
|
|
f20ce3d50f | ||
|
|
107152298e | ||
|
|
7a8b5f02d3 | ||
|
|
b508a5854a | ||
|
|
7167b40013 | ||
|
|
6965ba573e | ||
|
|
9f303b5311 | ||
|
|
8fc95e8189 | ||
|
|
5d51558c5c | ||
|
|
7181748d40 | ||
|
|
0dbb02d12a | ||
|
|
38b60e5626 | ||
|
|
246eb2b0bb | ||
|
|
d9a66749e7 | ||
|
|
8dfb1a4fb8 | ||
|
|
3a0ea0d321 | ||
|
|
baebc093f5 | ||
|
|
b4c3ebd254 | ||
|
|
257ac34224 | ||
|
|
159a28e890 | ||
|
|
328821cd14 | ||
|
|
46a30c6b80 | ||
|
|
8dfdc163bf | ||
|
|
13297fd017 | ||
|
|
0422066851 | ||
|
|
cf5ef80c08 | ||
|
|
c8e33c96ce | ||
|
|
2a6dbb4461 | ||
|
|
caba400f43 | ||
|
|
552fe6afa3 | ||
|
|
87e8df4ec5 | ||
|
|
4c192717de | ||
|
|
1bf32450fd |
@@ -8,3 +8,4 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
|
|||||||
- Theme system and customization
|
- Theme system and customization
|
||||||
- Command system reference
|
- Command system reference
|
||||||
- Configuration management
|
- Configuration management
|
||||||
|
- [Notification sounds](../../articles/notification-sounds.md)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Notification Sounds
|
||||||
|
|
||||||
|
EchoHub can play a notification sound when someone @mentions you. This is **disabled by default** and must be enabled in your profile settings.
|
||||||
|
|
||||||
|
## Enabling Notifications
|
||||||
|
|
||||||
|
Open your profile (`/profile`) and check the **"Notification sound on @mention"** checkbox, then save. You can also adjust the **Volume** (0-100, default 30). All settings are persisted in `~/.echohub/config.json`.
|
||||||
|
|
||||||
|
## Customizing the Sound
|
||||||
|
|
||||||
|
The client ships with a default `Notification.mp3` in the `Assets` folder. To use your own notification sound, replace the file at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<app-directory>/Assets/Notification.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
The file must be a valid `.mp3` or `.wav` audio file. The replacement takes effect on the next app launch.
|
||||||
|
|
||||||
|
Alternatively, set a custom path in `~/.echohub/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notifications": {
|
||||||
|
"enabled": true,
|
||||||
|
"volume": 30,
|
||||||
|
"soundFile": "/path/to/your/sound.mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `soundFile` is set, EchoHub uses that file instead of the bundled default.
|
||||||
|
|
||||||
|
## Disabling Notifications
|
||||||
|
|
||||||
|
Uncheck the option in your profile, or edit the config directly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notifications": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -4,6 +4,9 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
|
||||||
|
- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes
|
||||||
|
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
|
||||||
- [v0.2.0](v0.2.0.md) - IRC Gateway
|
- [v0.2.0](v0.2.0.md) - IRC Gateway
|
||||||
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
|
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
|
||||||
- [v0.1.0](v0.1.0.md) - Initial Release
|
- [v0.1.0](v0.1.0.md) - Initial Release
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.3
|
||||||
|
href: v0.2.3.md
|
||||||
|
- name: v0.2.2
|
||||||
|
href: v0.2.2.md
|
||||||
|
- name: v0.2.1
|
||||||
|
href: v0.2.1.md
|
||||||
- name: v0.2.0
|
- name: v0.2.0
|
||||||
href: v0.2.0.md
|
href: v0.2.0.md
|
||||||
- name: v0.1.1
|
- name: v0.1.1
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# v0.2.1 - Shutdown & CI Fixes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed server hanging on Ctrl+C when the directory server is unreachable — `StopAsync` now cancels background services before disposing connections
|
||||||
|
- Fixed IRC gateway shutdown blocking indefinitely on unresponsive clients — send operations are now bounded to 2 seconds
|
||||||
|
- Fixed CI release workflow not having full git history for building release notes (`fetch-depth: 0`)
|
||||||
|
- Fixed `workflow_dispatch` trigger breaking change detection when `github.event.before` is empty
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- GitHub releases now include a commit list and version diff link instead of generic auto-generated notes
|
||||||
|
- GitHub releases link to the full changelog on the docs site
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# v0.2.2 - Startup & Shutdown Fixes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed server hanging on startup when IRC gateway is enabled — circular DI dependency between `IrcGatewayService` → `IChatService` → `IChatBroadcaster` → `IrcBroadcaster` caused the DI container to deadlock
|
||||||
|
- Fixed `SignalRBroadcaster` eagerly resolving `IHubContext<ChatHub>` during DI construction, which could deadlock on some platforms — now lazy-resolves via `IServiceProvider` on first use
|
||||||
|
- Simplified IRC service registration to use standard `AddSingleton<IChatBroadcaster, IrcBroadcaster>` instead of manual factory, breaking the circular resolution chain
|
||||||
|
- Fixed server hanging on Ctrl+C — replaced `await using` with explicit dispose bounded to 3 seconds, so a stuck `HubConnection` can no longer block shutdown
|
||||||
|
- Reduced host shutdown timeout from 30s (default) to 5s
|
||||||
|
- Caught `OperationCanceledException` in the directory service reconnect loop so cancellation exits immediately instead of propagating through dispose
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Replaced primary constructors with standard constructor injection across all server classes for consistency
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# v0.2.3 - Moderation, Embeds & UI Overhaul
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Moderation System
|
||||||
|
|
||||||
|
- Added server roles: Owner, Admin, Mod, Member — first registered user is automatically Owner
|
||||||
|
- New `/kick`, `/ban`, `/unban`, `/mute`, `/unmute`, `/role`, `/nuke` commands for moderators and admins
|
||||||
|
- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking
|
||||||
|
- Mutes support optional duration (auto-expire) and blocked users cannot log in
|
||||||
|
- Role claim included in JWT tokens; role badges shown in the online users panel
|
||||||
|
- Kicked and banned users are forcibly disconnected in real time — server cleans up presence, broadcasts departures, and signals client disconnect
|
||||||
|
- Works for both SignalR and IRC connections; client shows an error dialog with the reason
|
||||||
|
|
||||||
|
### Private Channels
|
||||||
|
|
||||||
|
- Channels can be created as public or private via a checkbox in the Create Channel dialog
|
||||||
|
- Public channels are visible to all users; private channels only appear for members who joined them
|
||||||
|
- Persistent channel membership tracked in the database (`ChannelMembership` table)
|
||||||
|
- `GET /api/channels` returns the combined list: public channels + user's joined private channels
|
||||||
|
- Channel creators are automatically added as members
|
||||||
|
|
||||||
|
### OpenGraph Link Embeds
|
||||||
|
|
||||||
|
- Messages containing URLs now show a rich preview below the message text
|
||||||
|
- Multiple URLs per message supported (up to 3) — each gets its own embed
|
||||||
|
- Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`)
|
||||||
|
- Embeds are persisted in the database as a JSON array and included in channel history
|
||||||
|
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray; text word-wraps at actual viewport width
|
||||||
|
- IRC gateway receives a text-only embed preview (site name, title, description)
|
||||||
|
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
|
||||||
|
- 5-second fetch timeout ensures message delivery is never significantly delayed
|
||||||
|
- SSRF protection rejects private/loopback IP addresses before fetching
|
||||||
|
|
||||||
|
### Notification Sounds
|
||||||
|
|
||||||
|
- Incoming messages play a notification sound when the terminal is not focused
|
||||||
|
- Embedded MP3 asset with cross-platform playback support
|
||||||
|
|
||||||
|
### Online Users Panel
|
||||||
|
|
||||||
|
- Collapsible right-side panel showing online users in the current channel (toggle with F2)
|
||||||
|
- Users displayed with status indicators, role badges, and their custom nickname colors
|
||||||
|
- Panel updates on join, leave, and status change events
|
||||||
|
|
||||||
|
### @mention Highlighting
|
||||||
|
|
||||||
|
- `@username` text rendered in orange accent color in all messages
|
||||||
|
- Messages mentioning the current user get a full-line amber background highlight
|
||||||
|
- Works across multi-line messages and continuation lines
|
||||||
|
|
||||||
|
### ASCII Art Improvements
|
||||||
|
|
||||||
|
- Half-block character rendering (`▀`/`█`) with separate foreground + background colors for 2x vertical resolution
|
||||||
|
- Switched from ANSI escape codes to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`) — no control bytes in stored content
|
||||||
|
- Optional size parameter for `/send` command: `-s` (40x40), `-m` (80x80, default), `-l` (120x120)
|
||||||
|
- IRC gateway converts color tags back to ANSI for IRC client compatibility
|
||||||
|
|
||||||
|
### Client UI
|
||||||
|
|
||||||
|
- Version number shown in the status bar
|
||||||
|
- Custom colored rendering for channel list (active indicator, unread count badges)
|
||||||
|
- Avatar upload field added to the profile edit dialog (file path or URL)
|
||||||
|
- Profile avatar now renders with full color tag support in the profile view dialog
|
||||||
|
- Update check notification on connect — shows a system message if a newer GitHub release exists
|
||||||
|
- Chat messages no longer show selection/focus highlight
|
||||||
|
- Exit shortcut changed from Ctrl+C to Alt+Q — frees Ctrl+C for copy
|
||||||
|
- Default history increased from 50 to 100 messages on channel join
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed `#general` channel not visible after adding the `IsPublic` column — migration default changed to `true` and startup service ensures it
|
||||||
|
- Fixed channels disappearing when creating a new channel — replaced full re-fetch with incremental updates
|
||||||
|
- Wired `OnChannelUpdated` SignalR event so new public channels appear for all connected users in real time
|
||||||
|
- Fixed color tag parser using wrong regex group numbers (6,7,8 instead of 1,2,3) — new ASCII art was rendering without colors
|
||||||
|
- Full Unicode/emoji support — renderers use Terminal.Gui v2 grapheme cluster API (`GraphemeHelper`, `AddStr`) for proper wide character handling
|
||||||
|
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
|
||||||
|
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
|
||||||
|
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
|
||||||
|
- Fixed OG tag regex truncating descriptions containing apostrophes (e.g. `"HueByte's portfolio"` was cut to `"HueByte"`) — switched to backreference-based quote pairing
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via compiled regex
|
||||||
|
- `EmbedDto` record added to shared Core DTOs; `MessageDto.Embeds` list for multiple embeds per message
|
||||||
|
- `EmbedJson` nullable column on the `Message` table stores serialized embed data as JSON array (max 8KB); `DataMigrationService` auto-migrates old single-object format
|
||||||
|
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
|
||||||
|
- `PresenceTracker.ForceRemoveUser()` for atomic user cleanup on kick/ban
|
||||||
|
- `IChatBroadcaster.ForceDisconnectUserAsync()` and `IEchoHubClient.ForceDisconnect` for force-disconnect signaling
|
||||||
|
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
|
||||||
|
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
|
||||||
|
- `EmojiHelper` utility for emoji-to-shortcode conversion
|
||||||
|
- Heartbeat handling in `ServerDirectoryService` for connection health checks
|
||||||
|
- Four new EF Core migrations: `AddModerationRoles`, `AddChannelIsPublic`, `AddChannelMembership`, `AddMessageEmbed`
|
||||||
|
- `ChannelMembership` table with cascade delete on both channel and user removal
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.1</Version>
|
<Version>0.2.3</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly IApplication _app;
|
private readonly IApplication _app;
|
||||||
private readonly MainWindow _mainWindow;
|
private readonly MainWindow _mainWindow;
|
||||||
private readonly CommandHandler _commandHandler;
|
private readonly CommandHandler _commandHandler;
|
||||||
|
private readonly NotificationSoundService _notificationSound;
|
||||||
|
|
||||||
private EchoHubConnection? _connection;
|
private EchoHubConnection? _connection;
|
||||||
private ApiClient? _apiClient;
|
private ApiClient? _apiClient;
|
||||||
@@ -41,6 +42,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_config = config;
|
_config = config;
|
||||||
_mainWindow = new MainWindow(app);
|
_mainWindow = new MainWindow(app);
|
||||||
_commandHandler = new CommandHandler();
|
_commandHandler = new CommandHandler();
|
||||||
|
_notificationSound = new NotificationSoundService(config.Notifications);
|
||||||
|
|
||||||
WireMainWindowEvents();
|
WireMainWindowEvents();
|
||||||
WireCommandHandlerEvents();
|
WireCommandHandlerEvents();
|
||||||
@@ -76,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnThemeSelected += HandleThemeSelected;
|
_mainWindow.OnThemeSelected += HandleThemeSelected;
|
||||||
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
||||||
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
||||||
|
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -116,7 +119,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnSendFile += async (target) =>
|
_commandHandler.OnSendFile += async (target, size) =>
|
||||||
{
|
{
|
||||||
if (!IsAuthenticated || !IsConnected) return;
|
if (!IsAuthenticated || !IsConnected) return;
|
||||||
|
|
||||||
@@ -128,13 +131,13 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
await _apiClient!.SendUrlAsync(channel, target);
|
await _apiClient!.SendUrlAsync(channel, target, size);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await using var stream = File.OpenRead(target);
|
await using var stream = File.OpenRead(target);
|
||||||
var fileName = Path.GetFileName(target);
|
var fileName = Path.GetFileName(target);
|
||||||
await _apiClient!.UploadFileAsync(channel, stream, fileName);
|
await _apiClient!.UploadFileAsync(channel, stream, fileName, size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -211,6 +214,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var history = await _connection!.JoinChannelAsync(channelName);
|
var history = await _connection!.JoinChannelAsync(channelName);
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
|
// Add to channel list if not already there (e.g. private channels)
|
||||||
|
_mainWindow.EnsureChannelInList(channelName);
|
||||||
_mainWindow.SwitchToChannel(channelName);
|
_mainWindow.SwitchToChannel(channelName);
|
||||||
if (history.Count > 0)
|
if (history.Count > 0)
|
||||||
_mainWindow.LoadHistory(channelName, history);
|
_mainWindow.LoadHistory(channelName, history);
|
||||||
@@ -292,6 +297,61 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnKickUser += async (username, reason) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.KickUserAsync(username, reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnBanUser += async (username, reason) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.BanUserAsync(username, reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnUnbanUser += async (username) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.UnbanUserAsync(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnMuteUser += async (username, duration) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.MuteUserAsync(username, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnUnmuteUser += async (username) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.UnmuteUserAsync(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnAssignRole += async (username, roleStr) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
var role = roleStr switch
|
||||||
|
{
|
||||||
|
"admin" => ServerRole.Admin,
|
||||||
|
"mod" => ServerRole.Mod,
|
||||||
|
_ => ServerRole.Member,
|
||||||
|
};
|
||||||
|
await _apiClient!.AssignRoleAsync(username, role);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnNukeChannel += async () =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
await _apiClient!.NukeChannelAsync(channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnTestSound += async () =>
|
||||||
|
{
|
||||||
|
await _notificationSound.PlayTestAsync();
|
||||||
|
};
|
||||||
|
|
||||||
_commandHandler.OnQuit += () =>
|
_commandHandler.OnQuit += () =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _app.RequestStop());
|
InvokeUI(() => _app.RequestStop());
|
||||||
@@ -361,7 +421,20 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
// History might not be available
|
// History might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
SaveServerToConfig(result);
|
SaveServerToConfig(result);
|
||||||
|
|
||||||
|
// Check for newer version in the background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var newVersion = await UpdateChecker.CheckForUpdateAsync();
|
||||||
|
if (newVersion is not null)
|
||||||
|
{
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(
|
||||||
|
HubConstants.DefaultChannel,
|
||||||
|
$"A new version of EchoHub is available: v{newVersion} (current: v{MainWindow.AppVersion}). Visit https://github.com/HueByte/EchoHub/releases"));
|
||||||
|
}
|
||||||
|
});
|
||||||
}, "Connection failed", "Connect");
|
}, "Connection failed", "Connect");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +513,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
// History might not be available
|
// History might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
}, "Failed to join channel");
|
}, "Failed to join channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +579,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var editResult = ProfileEditDialog.Show(_app,
|
var editResult = ProfileEditDialog.Show(_app,
|
||||||
currentProfile?.DisplayName,
|
currentProfile?.DisplayName,
|
||||||
currentProfile?.Bio,
|
currentProfile?.Bio,
|
||||||
currentProfile?.NicknameColor);
|
currentProfile?.NicknameColor,
|
||||||
|
_config.Notifications.Enabled,
|
||||||
|
_config.Notifications.Volume);
|
||||||
|
|
||||||
if (editResult is null) return;
|
if (editResult is null) return;
|
||||||
|
|
||||||
@@ -526,6 +603,57 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload avatar if specified
|
||||||
|
if (editResult.AvatarPath is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Stream stream;
|
||||||
|
string fileName;
|
||||||
|
|
||||||
|
if (Uri.TryCreate(editResult.AvatarPath, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var bytes = await http.GetByteArrayAsync(uri);
|
||||||
|
stream = new MemoryStream(bytes);
|
||||||
|
fileName = Path.GetFileName(uri.LocalPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||||
|
fileName = "avatar.png";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stream = File.OpenRead(editResult.AvatarPath);
|
||||||
|
fileName = Path.GetFileName(editResult.AvatarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (stream)
|
||||||
|
{
|
||||||
|
await _apiClient!.UploadAvatarAsync(stream, fileName);
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Avatar upload failed for {Target}", editResult.AvatarPath);
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editResult.NotificationSoundEnabled.HasValue)
|
||||||
|
{
|
||||||
|
_config.Notifications.Enabled = editResult.NotificationSoundEnabled.Value;
|
||||||
|
_notificationSound.SetEnabled(editResult.NotificationSoundEnabled.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editResult.NotificationVolume.HasValue)
|
||||||
|
{
|
||||||
|
_config.Notifications.Volume = editResult.NotificationVolume.Value;
|
||||||
|
_notificationSound.SetVolume(editResult.NotificationVolume.Value);
|
||||||
|
}
|
||||||
|
|
||||||
_config.DefaultPreset = new AccountPreset
|
_config.DefaultPreset = new AccountPreset
|
||||||
{
|
{
|
||||||
DisplayName = editResult.DisplayName,
|
DisplayName = editResult.DisplayName,
|
||||||
@@ -599,17 +727,16 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic);
|
var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
|
||||||
if (channel is null) return;
|
if (channel is null) return;
|
||||||
|
|
||||||
_joinedChannels.Add(channel.Name);
|
_joinedChannels.Add(channel.Name);
|
||||||
var history = await _connection!.JoinChannelAsync(channel.Name);
|
var history = await _connection!.JoinChannelAsync(channel.Name);
|
||||||
|
|
||||||
// Refresh the channel list
|
|
||||||
var channels = await _apiClient.GetChannelsAsync();
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.SetChannels(channels);
|
_mainWindow.EnsureChannelInList(channel.Name);
|
||||||
|
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||||
_mainWindow.SwitchToChannel(channel.Name);
|
_mainWindow.SwitchToChannel(channel.Name);
|
||||||
if (history.Count > 0)
|
if (history.Count > 0)
|
||||||
_mainWindow.LoadHistory(channel.Name, history);
|
_mainWindow.LoadHistory(channel.Name, history);
|
||||||
@@ -617,18 +744,74 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to create channel");
|
}, "Failed to create channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleDeleteChannelRequested()
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated || !IsConnected)
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError("Not connected to a server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError("No channel selected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel == HubConstants.DefaultChannel)
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError($"The #{HubConstants.DefaultChannel} channel cannot be deleted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirm = MessageBox.Query(_app, "Delete Channel",
|
||||||
|
$"Are you sure you want to delete #{channel}?\nThis will remove all messages permanently.", "Delete", "Cancel");
|
||||||
|
|
||||||
|
if (confirm != 0) return;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
await _apiClient!.DeleteChannelAsync(channel);
|
||||||
|
_joinedChannels.Remove(channel);
|
||||||
|
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.RemoveChannel(channel);
|
||||||
|
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
||||||
|
_mainWindow.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted.");
|
||||||
|
});
|
||||||
|
}, "Failed to delete channel");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Connection Event Wiring ────────────────────────────────────────────
|
// ── Connection Event Wiring ────────────────────────────────────────────
|
||||||
|
|
||||||
private void WireConnectionEvents(EchoHubConnection connection)
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
{
|
{
|
||||||
connection.OnMessageReceived += message =>
|
connection.OnMessageReceived += message =>
|
||||||
|
{
|
||||||
InvokeUI(() => _mainWindow.AddMessage(message));
|
InvokeUI(() => _mainWindow.AddMessage(message));
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_currentUsername)
|
||||||
|
&& message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_ = _notificationSound.PlayAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
connection.OnUserJoined += (channelName, username) =>
|
connection.OnUserJoined += (channelName, username) =>
|
||||||
|
{
|
||||||
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
|
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
|
||||||
|
if (channelName == _mainWindow.CurrentChannel)
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
connection.OnUserLeft += (channelName, username) =>
|
connection.OnUserLeft += (channelName, username) =>
|
||||||
|
{
|
||||||
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel"));
|
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel"));
|
||||||
|
if (channelName == _mainWindow.CurrentChannel)
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
connection.OnUserStatusChanged += presence =>
|
connection.OnUserStatusChanged += presence =>
|
||||||
{
|
{
|
||||||
@@ -642,6 +825,67 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
foreach (var channelName in _mainWindow.GetChannelNames())
|
foreach (var channelName in _mainWindow.GetChannelNames())
|
||||||
_mainWindow.AddStatusMessage(channelName, displayName, statusText);
|
_mainWindow.AddStatusMessage(channelName, displayName, statusText);
|
||||||
});
|
});
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserKicked += (channelName, username, reason) =>
|
||||||
|
{
|
||||||
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.AddSystemMessage(channelName, $"{username} was kicked{reasonText}");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserBanned += (username, reason) =>
|
||||||
|
{
|
||||||
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
// Show ban notification for other users in the channel
|
||||||
|
if (!username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
_mainWindow.AddSystemMessage(channel, $"{username} was banned{reasonText}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnForceDisconnect += reason =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError(reason);
|
||||||
|
HandleDisconnect();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnMessageDeleted += (channelName, messageId) =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.RemoveMessage(channelName, messageId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnChannelNuked += channelName =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.ClearChannelMessages(channelName);
|
||||||
|
_mainWindow.AddSystemMessage(channelName, "Channel history has been cleared by a moderator.");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnChannelUpdated += channel =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
if (channel.IsPublic)
|
||||||
|
_mainWindow.EnsureChannelInList(channel.Name);
|
||||||
|
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
connection.OnError += errorMessage =>
|
connection.OnError += errorMessage =>
|
||||||
@@ -673,6 +917,25 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
// ── Private Helpers ────────────────────────────────────────────────────
|
// ── Private Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void FetchAndUpdateOnlineUsers()
|
||||||
|
{
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel) || !IsConnected) return;
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var users = await _connection!.GetOnlineUsersAsync(channel);
|
||||||
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Failed to fetch online users for {Channel}", channel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void SaveServerToConfig(ConnectDialogResult result)
|
private void SaveServerToConfig(ConnectDialogResult result)
|
||||||
{
|
{
|
||||||
var savedServer = new SavedServer
|
var savedServer = new SavedServer
|
||||||
|
|||||||
Binary file not shown.
@@ -10,7 +10,7 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnSetNick;
|
public event Func<string, Task>? OnSetNick;
|
||||||
public event Func<string, Task>? OnSetColor;
|
public event Func<string, Task>? OnSetColor;
|
||||||
public event Func<string, Task>? OnSetTheme;
|
public event Func<string, Task>? OnSetTheme;
|
||||||
public event Func<string, Task>? OnSendFile;
|
public event Func<string, string?, Task>? OnSendFile;
|
||||||
public event Func<string?, Task>? OnOpenProfile;
|
public event Func<string?, Task>? OnOpenProfile;
|
||||||
public event Func<Task>? OnOpenServers;
|
public event Func<Task>? OnOpenServers;
|
||||||
public event Func<string, Task>? OnJoinChannel;
|
public event Func<string, Task>? OnJoinChannel;
|
||||||
@@ -18,6 +18,14 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnSetTopic;
|
public event Func<string, Task>? OnSetTopic;
|
||||||
public event Func<Task>? OnListUsers;
|
public event Func<Task>? OnListUsers;
|
||||||
public event Func<string, Task>? OnSetAvatar;
|
public event Func<string, Task>? OnSetAvatar;
|
||||||
|
public event Func<string, string?, Task>? OnKickUser;
|
||||||
|
public event Func<string, string?, Task>? OnBanUser;
|
||||||
|
public event Func<string, Task>? OnUnbanUser;
|
||||||
|
public event Func<string, int?, Task>? OnMuteUser;
|
||||||
|
public event Func<string, Task>? OnUnmuteUser;
|
||||||
|
public event Func<string, string, Task>? OnAssignRole;
|
||||||
|
public event Func<Task>? OnNukeChannel;
|
||||||
|
public event Func<Task>? OnTestSound;
|
||||||
public event Func<Task>? OnQuit;
|
public event Func<Task>? OnQuit;
|
||||||
public event Func<Task>? OnHelp;
|
public event Func<Task>? OnHelp;
|
||||||
|
|
||||||
@@ -46,6 +54,14 @@ public class CommandHandler
|
|||||||
"leave" => await HandleLeave(),
|
"leave" => await HandleLeave(),
|
||||||
"topic" => await HandleTopic(args),
|
"topic" => await HandleTopic(args),
|
||||||
"users" => await HandleUsers(),
|
"users" => await HandleUsers(),
|
||||||
|
"kick" => await HandleKick(args),
|
||||||
|
"ban" => await HandleBan(args),
|
||||||
|
"unban" => await HandleUnban(args),
|
||||||
|
"mute" => await HandleMute(args),
|
||||||
|
"unmute" => await HandleUnmute(args),
|
||||||
|
"role" => await HandleRole(args),
|
||||||
|
"nuke" => await HandleNuke(),
|
||||||
|
"test-sound" => await HandleTestSound(),
|
||||||
"quit" or "exit" => await HandleQuit(),
|
"quit" or "exit" => await HandleQuit(),
|
||||||
"help" or "?" => await HandleHelp(),
|
"help" or "?" => await HandleHelp(),
|
||||||
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
||||||
@@ -120,15 +136,19 @@ public class CommandHandler
|
|||||||
private async Task<CommandResult> HandleSend(string args)
|
private async Task<CommandResult> HandleSend(string args)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true);
|
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
|
||||||
|
|
||||||
var target = args.Trim().Trim('"');
|
// Extract optional size flag from end or start, respecting quoted paths
|
||||||
|
var (target, size) = ParsePathAndSizeFlag(args);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(target))
|
||||||
|
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
|
||||||
|
|
||||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
if (OnSendFile is not null)
|
if (OnSendFile is not null)
|
||||||
await OnSendFile(target);
|
await OnSendFile(target, size);
|
||||||
var fileName = Path.GetFileName(uri.LocalPath);
|
var fileName = Path.GetFileName(uri.LocalPath);
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
fileName = "image";
|
fileName = "image";
|
||||||
@@ -139,7 +159,7 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, $"File not found: {target}", IsError: true);
|
return new CommandResult(true, $"File not found: {target}", IsError: true);
|
||||||
|
|
||||||
if (OnSendFile is not null)
|
if (OnSendFile is not null)
|
||||||
await OnSendFile(target);
|
await OnSendFile(target, size);
|
||||||
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +176,7 @@ public class CommandHandler
|
|||||||
if (string.IsNullOrWhiteSpace(args))
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
|
return new CommandResult(true, "Usage: /avatar <URL or filepath>", IsError: true);
|
||||||
|
|
||||||
var target = args.Trim().Trim('"');
|
var target = StripQuotes(args.Trim());
|
||||||
|
|
||||||
if (OnSetAvatar is not null)
|
if (OnSetAvatar is not null)
|
||||||
await OnSetAvatar(target);
|
await OnSetAvatar(target);
|
||||||
@@ -212,6 +232,102 @@ public class CommandHandler
|
|||||||
return new CommandResult(true);
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleKick(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /kick <username> [reason]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
var reason = parts.Length > 1 ? parts[1] : null;
|
||||||
|
|
||||||
|
if (OnKickUser is not null)
|
||||||
|
await OnKickUser(username, reason);
|
||||||
|
return new CommandResult(true, $"Kicking {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleBan(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /ban <username> [reason]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
var reason = parts.Length > 1 ? parts[1] : null;
|
||||||
|
|
||||||
|
if (OnBanUser is not null)
|
||||||
|
await OnBanUser(username, reason);
|
||||||
|
return new CommandResult(true, $"Banning {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUnban(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /unban <username>", IsError: true);
|
||||||
|
|
||||||
|
if (OnUnbanUser is not null)
|
||||||
|
await OnUnbanUser(args.Trim());
|
||||||
|
return new CommandResult(true, $"Unbanning {args.Trim()}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleMute(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /mute <username> [duration_minutes]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
int? duration = parts.Length > 1 && int.TryParse(parts[1], out var d) ? d : null;
|
||||||
|
|
||||||
|
if (OnMuteUser is not null)
|
||||||
|
await OnMuteUser(username, duration);
|
||||||
|
return new CommandResult(true, $"Muting {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUnmute(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /unmute <username>", IsError: true);
|
||||||
|
|
||||||
|
if (OnUnmuteUser is not null)
|
||||||
|
await OnUnmuteUser(args.Trim());
|
||||||
|
return new CommandResult(true, $"Unmuting {args.Trim()}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleRole(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
|
||||||
|
|
||||||
|
var username = parts[0];
|
||||||
|
var role = parts[1].ToLowerInvariant();
|
||||||
|
|
||||||
|
if (role is not ("admin" or "mod" or "member"))
|
||||||
|
return new CommandResult(true, "Invalid role. Use: admin, mod, or member", IsError: true);
|
||||||
|
|
||||||
|
if (OnAssignRole is not null)
|
||||||
|
await OnAssignRole(username, role);
|
||||||
|
return new CommandResult(true, $"Setting {username} to {role}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleNuke()
|
||||||
|
{
|
||||||
|
if (OnNukeChannel is not null)
|
||||||
|
await OnNukeChannel();
|
||||||
|
return new CommandResult(true, "Nuking channel history...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleTestSound()
|
||||||
|
{
|
||||||
|
if (OnTestSound is not null)
|
||||||
|
await OnTestSound();
|
||||||
|
return new CommandResult(true, "Playing notification sound...");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleHelp()
|
private async Task<CommandResult> HandleHelp()
|
||||||
{
|
{
|
||||||
if (OnHelp is not null)
|
if (OnHelp is not null)
|
||||||
@@ -223,18 +339,72 @@ public class CommandHandler
|
|||||||
/nick <name> - Set display name
|
/nick <name> - Set display name
|
||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> - Send a file or image
|
/send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large)
|
||||||
/avatar <URL or filepath> - Set your avatar
|
/avatar <URL or filepath> - Set your avatar
|
||||||
/profile [username] - View a profile (yours if no name given)
|
/profile [username] - View a profile
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
/join <channel> - Join a channel
|
/join <channel> - Join a channel
|
||||||
/leave - Leave current channel
|
/leave - Leave current channel
|
||||||
/topic <text> - Set channel topic
|
/topic <text> - Set channel topic
|
||||||
/users - List online users
|
/users - List online users
|
||||||
|
Moderation:
|
||||||
|
/kick <user> [reason] - Kick a user (Mod+)
|
||||||
|
/ban <user> [reason] - Ban a user (Admin+)
|
||||||
|
/unban <user> - Unban a user (Admin+)
|
||||||
|
/mute <user> [minutes] - Mute a user (Mod+)
|
||||||
|
/unmute <user> - Unmute a user (Mod+)
|
||||||
|
/role <user> <admin|mod|member> - Assign role (Admin+)
|
||||||
|
/nuke - Clear channel history (Mod+)
|
||||||
|
/test-sound - Play notification sound
|
||||||
/quit - Exit the app
|
/quit - Exit the app
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract a file path (possibly quoted) and an optional size flag (-s, -m, -l).
|
||||||
|
/// The flag can appear before or after the path.
|
||||||
|
/// </summary>
|
||||||
|
private static (string Path, string? Size) ParsePathAndSizeFlag(string args)
|
||||||
|
{
|
||||||
|
var trimmed = args.Trim();
|
||||||
|
string? size = null;
|
||||||
|
|
||||||
|
// Check for flag at the end: "path" -m or path -m
|
||||||
|
if (trimmed.Length > 3)
|
||||||
|
{
|
||||||
|
var suffix = trimmed[^2..];
|
||||||
|
if (suffix is "-s" or "-m" or "-l" && trimmed[^3] == ' ')
|
||||||
|
{
|
||||||
|
size = suffix[1..];
|
||||||
|
trimmed = trimmed[..^3].TrimEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for flag at the start: -m "path" or -m path
|
||||||
|
if (size is null && trimmed.Length > 3)
|
||||||
|
{
|
||||||
|
var prefix = trimmed[..2];
|
||||||
|
if (prefix is "-s" or "-m" or "-l" && trimmed[2] == ' ')
|
||||||
|
{
|
||||||
|
size = prefix[1..];
|
||||||
|
trimmed = trimmed[3..].TrimStart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (StripQuotes(trimmed), size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove matching surrounding quotes (double or single) from a string.
|
||||||
|
/// </summary>
|
||||||
|
private static string StripQuotes(string s)
|
||||||
|
{
|
||||||
|
if (s.Length >= 2 &&
|
||||||
|
((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\'')))
|
||||||
|
return s[1..^1];
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsValidHex(string s) =>
|
private static bool IsValidHex(string s) =>
|
||||||
s.All(c => char.IsAsciiHexDigit(c));
|
s.All(c => char.IsAsciiHexDigit(c));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ public class ClientConfig
|
|||||||
public List<SavedServer> SavedServers { get; set; } = [];
|
public List<SavedServer> SavedServers { get; set; } = [];
|
||||||
public AccountPreset DefaultPreset { get; set; } = new();
|
public AccountPreset DefaultPreset { get; set; } = new();
|
||||||
public string ActiveTheme { get; set; } = "Default";
|
public string ActiveTheme { get; set; } = "Default";
|
||||||
|
public NotificationConfig Notifications { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public byte Volume { get; set; } = 30;
|
||||||
|
public string? SoundFile { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SavedServer
|
public class SavedServer
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||||
|
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
@@ -17,6 +18,9 @@
|
|||||||
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Assets\**">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<EmbeddedResource Include="appsettings.example.json">
|
<EmbeddedResource Include="appsettings.example.json">
|
||||||
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using EchoHub.Client.Themes;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
|
||||||
|
|
||||||
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||||
if (!File.Exists(appSettingsPath))
|
if (!File.Exists(appSettingsPath))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Client.Services;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
@@ -159,7 +160,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
return result?.AvatarAscii;
|
return result?.AvatarAscii;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName)
|
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
using var content = new MultipartFormDataContent();
|
using var content = new MultipartFormDataContent();
|
||||||
@@ -167,26 +168,28 @@ public sealed class ApiClient : IDisposable
|
|||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||||
content.Add(streamContent, "file", fileName);
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content));
|
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MessageDto?> SendUrlAsync(string channelName, string url)
|
public async Task<MessageDto?> SendUrlAsync(string channelName, string url, string? size = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new SendUrlRequest(url);
|
var request = new SendUrlRequest(url);
|
||||||
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url", request));
|
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null)
|
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new CreateChannelRequest(name, topic);
|
var request = new CreateChannelRequest(name, topic, isPublic);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync("/api/channels", request));
|
_http.PostAsJsonAsync("/api/channels", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
@@ -211,6 +214,72 @@ public sealed class ApiClient : IDisposable
|
|||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Moderation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task KickUserAsync(string username, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task BanUserAsync(string username, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnbanUserAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnmuteUserAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteMessageAsync(Guid messageId)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NukeChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
private void SetTokens(LoginResponse result)
|
private void SetTokens(LoginResponse result)
|
||||||
{
|
{
|
||||||
_accessToken = result.Token;
|
_accessToken = result.Token;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
public event Action<string, string>? OnUserLeft;
|
public event Action<string, string>? OnUserLeft;
|
||||||
public event Action<ChannelDto>? OnChannelUpdated;
|
public event Action<ChannelDto>? OnChannelUpdated;
|
||||||
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||||
|
public event Action<string, string, string?>? OnUserKicked;
|
||||||
|
public event Action<string, string?>? OnUserBanned;
|
||||||
|
public event Action<string, Guid>? OnMessageDeleted;
|
||||||
|
public event Action<string>? OnChannelNuked;
|
||||||
|
public event Action<string>? OnForceDisconnect;
|
||||||
public event Action<string>? OnError;
|
public event Action<string>? OnError;
|
||||||
public event Action<string>? OnConnectionStateChanged;
|
public event Action<string>? OnConnectionStateChanged;
|
||||||
public event Action? OnReconnected;
|
public event Action? OnReconnected;
|
||||||
@@ -81,6 +86,31 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnUserStatusChanged?.Invoke(presence);
|
OnUserStatusChanged?.Invoke(presence);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string, string?>(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) =>
|
||||||
|
{
|
||||||
|
OnUserKicked?.Invoke(channelName, username, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string?>(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) =>
|
||||||
|
{
|
||||||
|
OnUserBanned?.Invoke(username, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, Guid>(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) =>
|
||||||
|
{
|
||||||
|
OnMessageDeleted?.Invoke(channelName, messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
|
||||||
|
{
|
||||||
|
OnChannelNuked?.Invoke(channelName);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ForceDisconnect), reason =>
|
||||||
|
{
|
||||||
|
OnForceDisconnect?.Invoke(reason);
|
||||||
|
});
|
||||||
|
|
||||||
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
||||||
{
|
{
|
||||||
OnError?.Invoke(message);
|
OnError?.Invoke(message);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using EchoHub.Client.Config;
|
||||||
|
using NetCoreAudio;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public class NotificationSoundService
|
||||||
|
{
|
||||||
|
private readonly Player _player = new();
|
||||||
|
private readonly NotificationConfig _config;
|
||||||
|
private string? _resolvedSoundPath;
|
||||||
|
|
||||||
|
public NotificationSoundService(NotificationConfig config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
ResolveSoundPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetEnabled(bool enabled) => _config.Enabled = enabled;
|
||||||
|
|
||||||
|
public void SetVolume(byte volume) => _config.Volume = Math.Min(volume, (byte)100);
|
||||||
|
|
||||||
|
public async Task PlayAsync()
|
||||||
|
{
|
||||||
|
if (!_config.Enabled || _resolvedSoundPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await PlayInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays the notification sound regardless of the Enabled setting (for /test-sound).
|
||||||
|
/// </summary>
|
||||||
|
public async Task PlayTestAsync()
|
||||||
|
{
|
||||||
|
if (_resolvedSoundPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await PlayInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PlayInternal()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Playing)
|
||||||
|
await _player.Stop();
|
||||||
|
|
||||||
|
await _player.SetVolume(_config.Volume);
|
||||||
|
await _player.Play(_resolvedSoundPath!);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to play notification sound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResolveSoundPath()
|
||||||
|
{
|
||||||
|
// 1. Explicit path from config (~/.echohub/config.json)
|
||||||
|
if (!string.IsNullOrWhiteSpace(_config.SoundFile))
|
||||||
|
{
|
||||||
|
if (File.Exists(_config.SoundFile))
|
||||||
|
{
|
||||||
|
_resolvedSoundPath = Path.GetFullPath(_config.SoundFile);
|
||||||
|
Log.Debug("Notification sound: {Path} (from config)", _resolvedSoundPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning("Configured sound file not found: {Path}", _config.SoundFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Default: Notification.mp3 bundled next to the executable
|
||||||
|
var defaultPath = Path.Combine(AppContext.BaseDirectory, "Assets", "Notification.mp3");
|
||||||
|
|
||||||
|
if (File.Exists(defaultPath))
|
||||||
|
{
|
||||||
|
_resolvedSoundPath = defaultPath;
|
||||||
|
Log.Debug("Notification sound: {Path} (default)", _resolvedSoundPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("No notification sound file found — notifications will be silent");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public static class UpdateChecker
|
||||||
|
{
|
||||||
|
private static readonly Uri ReleaseUrl =
|
||||||
|
new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks GitHub for a newer release. Returns the new version string if one exists, or null.
|
||||||
|
/// Never throws — all errors are silently swallowed.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<string?> CheckForUpdateAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
||||||
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
|
||||||
|
|
||||||
|
var release = await http.GetFromJsonAsync<GitHubRelease>(ReleaseUrl);
|
||||||
|
if (release?.TagName is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var tag = release.TagName.TrimStart('v', 'V');
|
||||||
|
if (!Version.TryParse(tag, out var latest))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var currentStr = typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3);
|
||||||
|
if (currentStr is null || !Version.TryParse(currentStr, out var current))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return latest > current ? tag : null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GitHubRelease
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tag_name")]
|
||||||
|
public string? TagName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
@@ -20,17 +20,19 @@ public partial class ChatLine
|
|||||||
{
|
{
|
||||||
public List<ChatSegment> Segments { get; }
|
public List<ChatSegment> Segments { get; }
|
||||||
public int TextLength { get; }
|
public int TextLength { get; }
|
||||||
|
public Guid? MessageId { get; set; }
|
||||||
|
public bool IsMention { get; set; }
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
public ChatLine(string plainText)
|
||||||
{
|
{
|
||||||
Segments = [new ChatSegment(plainText, null)];
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
TextLength = plainText.Length;
|
TextLength = plainText.GetColumns();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChatLine(List<ChatSegment> segments)
|
public ChatLine(List<ChatSegment> segments)
|
||||||
{
|
{
|
||||||
Segments = segments;
|
Segments = segments;
|
||||||
TextLength = segments.Sum(s => s.Text.Length);
|
TextLength = segments.Sum(s => s.Text.GetColumns());
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
|
||||||
@@ -50,17 +52,22 @@ public partial class ChatLine
|
|||||||
|
|
||||||
foreach (var segment in Segments)
|
foreach (var segment in Segments)
|
||||||
{
|
{
|
||||||
int segPos = 0;
|
var text = segment.Text;
|
||||||
while (segPos < segment.Text.Length)
|
int chunkStart = 0;
|
||||||
|
int charPos = 0;
|
||||||
|
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
||||||
{
|
{
|
||||||
int remaining = width - col;
|
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
if (remaining <= 0)
|
|
||||||
|
if (col + graphemeCols > width)
|
||||||
{
|
{
|
||||||
// Emit current line and start a new one
|
if (charPos > chunkStart)
|
||||||
|
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
|
||||||
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
results.Add(new ChatLine(currentSegments));
|
||||||
currentSegments = [];
|
currentSegments = [];
|
||||||
|
|
||||||
// Add indent for continuation
|
|
||||||
if (continuationIndent > 0)
|
if (continuationIndent > 0)
|
||||||
{
|
{
|
||||||
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
||||||
@@ -71,14 +78,15 @@ public partial class ChatLine
|
|||||||
col = 0;
|
col = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining = width - col;
|
chunkStart = charPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
int take = Math.Min(segment.Text.Length - segPos, remaining);
|
col += graphemeCols;
|
||||||
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
|
charPos += grapheme.Length;
|
||||||
col += take;
|
|
||||||
segPos += take;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chunkStart < text.Length)
|
||||||
|
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentSegments.Count > 0)
|
if (currentSegments.Count > 0)
|
||||||
@@ -88,62 +96,83 @@ public partial class ChatLine
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
|
/// Returns true if a line contains printable color tags.
|
||||||
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
|
public static bool HasColorTags(string text) =>
|
||||||
|
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all color tags from text, returning only the visible characters.
|
||||||
|
/// </summary>
|
||||||
|
public static string StripColorTags(string text) =>
|
||||||
|
ColorTagRegex().Replace(text, "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a string containing printable color tags into colored segments.
|
||||||
|
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
|
||||||
|
/// </summary>
|
||||||
|
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>();
|
var segments = new List<ChatSegment>();
|
||||||
var regex = AnsiColorRegex();
|
|
||||||
int lastIndex = 0;
|
int lastIndex = 0;
|
||||||
Attribute? currentColor = defaultAttr;
|
Color? currentFg = null;
|
||||||
|
Color? currentBg = null;
|
||||||
|
var defaultFg = defaultAttr?.Foreground;
|
||||||
|
var defaultBg = defaultAttr?.Background ?? Color.Black;
|
||||||
|
|
||||||
foreach (Match match in regex.Matches(ansiText))
|
Attribute? BuildAttr()
|
||||||
|
{
|
||||||
|
if (currentFg is null && currentBg is null) return defaultAttr;
|
||||||
|
var fg = currentFg ?? defaultFg ?? Color.White;
|
||||||
|
var bg = currentBg ?? defaultBg;
|
||||||
|
return new Attribute(fg, bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in ColorTagRegex().Matches(text))
|
||||||
{
|
{
|
||||||
// Add any text before this escape sequence
|
|
||||||
if (match.Index > lastIndex)
|
if (match.Index > lastIndex)
|
||||||
{
|
{
|
||||||
var text = ansiText[lastIndex..match.Index];
|
var t = text[lastIndex..match.Index];
|
||||||
if (text.Length > 0)
|
if (t.Length > 0)
|
||||||
segments.Add(new ChatSegment(text, currentColor));
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the escape sequence
|
if (match.Groups[1].Success)
|
||||||
if (match.Groups[1].Value == "0")
|
|
||||||
{
|
{
|
||||||
// Reset
|
currentFg = null;
|
||||||
currentColor = defaultAttr;
|
currentBg = null;
|
||||||
}
|
}
|
||||||
else if (match.Groups[2].Success)
|
else if (match.Groups[2].Success)
|
||||||
{
|
{
|
||||||
// 38;2;R;G;B — 24-bit foreground color
|
var hex = match.Groups[3].Value;
|
||||||
var r = int.Parse(match.Groups[3].Value);
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
var g = int.Parse(match.Groups[4].Value);
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
var b = int.Parse(match.Groups[5].Value);
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
currentColor = new Attribute(new Color(r, g, b), Color.Black);
|
if (match.Groups[2].Value == "F")
|
||||||
|
currentFg = new Color(r, g, b);
|
||||||
|
else
|
||||||
|
currentBg = new Color(r, g, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastIndex = match.Index + match.Length;
|
lastIndex = match.Index + match.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text
|
if (lastIndex < text.Length)
|
||||||
if (lastIndex < ansiText.Length)
|
|
||||||
{
|
{
|
||||||
var text = ansiText[lastIndex..];
|
var t = text[lastIndex..];
|
||||||
if (text.Length > 0)
|
if (t.Length > 0)
|
||||||
segments.Add(new ChatSegment(text, currentColor));
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
|
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
||||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
private static partial Regex ColorTagRegex();
|
||||||
private static partial Regex AnsiColorRegex();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Custom list data source for chat messages with per-character coloring.
|
/// Custom list data source for chat messages with per-segment coloring.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ChatListSource : IListDataSource
|
public class ChatListSource : IListDataSource
|
||||||
{
|
{
|
||||||
@@ -197,7 +226,8 @@ public class ChatListSource : IListDataSource
|
|||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
var chatLine = _lines[item];
|
var chatLine = _lines[item];
|
||||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||||
|
|
||||||
int charPos = 0;
|
int charPos = 0;
|
||||||
int drawnChars = 0;
|
int drawnChars = 0;
|
||||||
@@ -205,26 +235,26 @@ public class ChatListSource : IListDataSource
|
|||||||
foreach (var segment in chatLine.Segments)
|
foreach (var segment in chatLine.Segments)
|
||||||
{
|
{
|
||||||
var attr = segment.Color ?? normalAttr;
|
var attr = segment.Color ?? normalAttr;
|
||||||
|
if (mentionBg.HasValue)
|
||||||
|
attr = new Attribute(attr.Foreground, mentionBg.Value);
|
||||||
listView.SetAttribute(attr);
|
listView.SetAttribute(attr);
|
||||||
|
|
||||||
foreach (var ch in segment.Text)
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
{
|
{
|
||||||
if (charPos >= viewportX && drawnChars < width)
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (charPos >= viewportX && drawnChars + cols <= width)
|
||||||
{
|
{
|
||||||
listView.AddRune(new Rune(ch));
|
listView.AddStr(grapheme);
|
||||||
drawnChars++;
|
drawnChars += cols;
|
||||||
}
|
}
|
||||||
charPos++;
|
charPos += cols;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill remaining width with spaces using default colors
|
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
||||||
listView.SetAttribute(normalAttr);
|
listView.SetAttribute(fillAttr);
|
||||||
while (drawnChars < width)
|
for (int i = drawnChars; i < width; i++)
|
||||||
{
|
listView.AddStr(" ");
|
||||||
listView.AddRune(new Rune(' '));
|
|
||||||
drawnChars++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateMaxLength(ChatLine line)
|
private void UpdateMaxLength(ChatLine line)
|
||||||
@@ -242,13 +272,226 @@ public class ChatListSource : IListDataSource
|
|||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for colored channel list rendering.
|
||||||
|
/// Active channel gets a > indicator, unread channels are bright with a count badge.
|
||||||
|
/// </summary>
|
||||||
|
public class ChannelListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<string> _channelNames = [];
|
||||||
|
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||||
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _channelNames.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
private static readonly Attribute ActiveAttr = new(Color.White, Color.Black);
|
||||||
|
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black);
|
||||||
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black);
|
||||||
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black);
|
||||||
|
|
||||||
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
||||||
|
{
|
||||||
|
_channelNames.Clear();
|
||||||
|
_channelNames.AddRange(channels);
|
||||||
|
_unreadCounts.Clear();
|
||||||
|
foreach (var kv in unread)
|
||||||
|
_unreadCounts[kv.Key] = kv.Value;
|
||||||
|
_activeChannel = activeChannel;
|
||||||
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var name = _channelNames[item];
|
||||||
|
var isActive = name == _activeChannel;
|
||||||
|
_unreadCounts.TryGetValue(name, out var unread);
|
||||||
|
var hasUnread = unread > 0;
|
||||||
|
|
||||||
|
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||||
|
var prefix = isActive ? "> " : " ";
|
||||||
|
var channelText = $"#{name}";
|
||||||
|
var badge = hasUnread ? $" ({unread})" : "";
|
||||||
|
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(focusAttr);
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
listView.SetAttribute(isActive ? ActiveAttr : NormalAttr);
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
||||||
|
|
||||||
|
listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr);
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
||||||
|
|
||||||
|
if (hasUnread)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(BadgeAttr);
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for the online users panel with per-user nickname colors.
|
||||||
|
/// </summary>
|
||||||
|
public class UserListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<(string Text, Attribute? NameColor)> _users = [];
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _users.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
public void Update(List<(string Text, Attribute? NameColor)> users)
|
||||||
|
{
|
||||||
|
_users.Clear();
|
||||||
|
_users.AddRange(users);
|
||||||
|
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _users.Select(u => u.Text).ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var (text, nameColor) = _users[item];
|
||||||
|
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||||
|
|
||||||
|
// Find where the name starts (after status icon + space + optional role badge)
|
||||||
|
// Format: "● ★Username" or "● Username"
|
||||||
|
var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
|
||||||
|
int nameStart = 0;
|
||||||
|
while (nameStart < graphemes.Count)
|
||||||
|
{
|
||||||
|
var g = graphemes[nameStart];
|
||||||
|
if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
|
||||||
|
break;
|
||||||
|
nameStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
// Draw prefix (status icon + role badge) in normal color
|
||||||
|
listView.SetAttribute(normalAttr);
|
||||||
|
for (int i = 0; i < nameStart; i++)
|
||||||
|
{
|
||||||
|
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
||||||
|
if (drawnChars + cols > width) break;
|
||||||
|
listView.AddStr(graphemes[i]);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw name in nickname color
|
||||||
|
var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
|
||||||
|
listView.SetAttribute(userAttr);
|
||||||
|
for (int i = nameStart; i < graphemes.Count; i++)
|
||||||
|
{
|
||||||
|
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
||||||
|
if (drawnChars + cols > width) break;
|
||||||
|
listView.AddStr(graphemes[i]);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill rest
|
||||||
|
listView.SetAttribute(normalAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared rendering helpers for IListDataSource implementations.
|
||||||
|
/// </summary>
|
||||||
|
static class RenderHelpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
|
||||||
|
/// Returns the updated drawn-columns count.
|
||||||
|
/// </summary>
|
||||||
|
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
|
||||||
|
{
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
||||||
|
{
|
||||||
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (drawn + cols > maxWidth) break;
|
||||||
|
lv.AddStr(grapheme);
|
||||||
|
drawn += cols;
|
||||||
|
}
|
||||||
|
return drawn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared color attributes for chat rendering (timestamps, system messages).
|
/// Shared color attributes for chat rendering (timestamps, system messages).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ChatColors
|
public static partial class ChatColors
|
||||||
{
|
{
|
||||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
||||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
||||||
|
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
||||||
|
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.Black);
|
||||||
|
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black);
|
||||||
|
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black);
|
||||||
|
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black);
|
||||||
|
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
||||||
|
/// Non-mention text uses the provided default color.
|
||||||
|
/// </summary>
|
||||||
|
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
int lastIndex = 0;
|
||||||
|
|
||||||
|
foreach (Match match in MentionRegex().Matches(text))
|
||||||
|
{
|
||||||
|
if (match.Index > lastIndex)
|
||||||
|
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
|
||||||
|
|
||||||
|
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
|
||||||
|
lastIndex = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.Length)
|
||||||
|
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"@[\w-]+")]
|
||||||
|
private static partial Regex MentionRegex();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase;
|
|||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
public record CreateChannelResult(string Name, string? Topic);
|
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
||||||
|
|
||||||
public sealed class CreateChannelDialog
|
public sealed class CreateChannelDialog
|
||||||
{
|
{
|
||||||
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
|
|||||||
{
|
{
|
||||||
CreateChannelResult? result = null;
|
CreateChannelResult? result = null;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
|
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 };
|
||||||
|
|
||||||
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
||||||
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
||||||
@@ -20,11 +20,19 @@ public sealed class CreateChannelDialog
|
|||||||
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
||||||
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
||||||
|
|
||||||
|
var publicCheckbox = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Public (visible to all users)",
|
||||||
|
X = 1,
|
||||||
|
Y = 5,
|
||||||
|
Value = CheckState.Checked
|
||||||
|
};
|
||||||
|
|
||||||
var hintLabel = new Label
|
var hintLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
||||||
X = 1,
|
X = 1,
|
||||||
Y = 5,
|
Y = 7,
|
||||||
};
|
};
|
||||||
|
|
||||||
var createButton = new Button
|
var createButton = new Button
|
||||||
@@ -32,14 +40,14 @@ public sealed class CreateChannelDialog
|
|||||||
Text = "Create",
|
Text = "Create",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 10,
|
X = Pos.Center() - 10,
|
||||||
Y = 7
|
Y = 9
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 5,
|
X = Pos.Center() + 5,
|
||||||
Y = 7
|
Y = 9
|
||||||
};
|
};
|
||||||
|
|
||||||
createButton.Accepting += (s, e) =>
|
createButton.Accepting += (s, e) =>
|
||||||
@@ -55,7 +63,8 @@ public sealed class CreateChannelDialog
|
|||||||
if (string.IsNullOrWhiteSpace(topic))
|
if (string.IsNullOrWhiteSpace(topic))
|
||||||
topic = null;
|
topic = null;
|
||||||
|
|
||||||
result = new CreateChannelResult(name, topic);
|
var isPublic = publicCheckbox.Value == CheckState.Checked;
|
||||||
|
result = new CreateChannelResult(name, topic, isPublic);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -67,7 +76,7 @@ public sealed class CreateChannelDialog
|
|||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
|
dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
|
||||||
|
|
||||||
nameField.SetFocus();
|
nameField.SetFocus();
|
||||||
app.Run(dialog);
|
app.Run(dialog);
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
|
||||||
|
/// Terminal width calculations for emoji are unreliable across different terminals,
|
||||||
|
/// so we replace them with fixed-width ASCII shortcodes for display only.
|
||||||
|
/// </summary>
|
||||||
|
public static class EmojiHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Replace emoji graphemes in the text with :shortcode: equivalents.
|
||||||
|
/// Non-emoji text passes through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static string ReplaceEmoji(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return text;
|
||||||
|
|
||||||
|
// Quick check: if no characters above BMP or supplementary emoji ranges, skip processing
|
||||||
|
bool hasEmoji = false;
|
||||||
|
foreach (var rune in text.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
{
|
||||||
|
hasEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEmoji)
|
||||||
|
return text;
|
||||||
|
|
||||||
|
var sb = new StringBuilder(text.Length);
|
||||||
|
var enumerator = StringInfo.GetTextElementEnumerator(text);
|
||||||
|
|
||||||
|
while (enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
var grapheme = enumerator.GetTextElement();
|
||||||
|
|
||||||
|
// Check if this grapheme contains emoji runes
|
||||||
|
bool graphemeHasEmoji = false;
|
||||||
|
foreach (var rune in grapheme.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
{
|
||||||
|
graphemeHasEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphemeHasEmoji)
|
||||||
|
{
|
||||||
|
// Try to find a shortcode for the whole grapheme first
|
||||||
|
if (EmojiShortcodes.TryGetValue(grapheme, out var shortcode))
|
||||||
|
{
|
||||||
|
sb.Append(shortcode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Try the base emoji (first rune only, stripping modifiers/ZWJ)
|
||||||
|
var baseRune = GetBaseEmoji(grapheme);
|
||||||
|
if (baseRune is not null && EmojiShortcodes.TryGetValue(baseRune, out shortcode))
|
||||||
|
{
|
||||||
|
sb.Append(shortcode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unknown emoji — use generic placeholder
|
||||||
|
sb.Append("[emoji]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(grapheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmojiRune(Rune rune)
|
||||||
|
{
|
||||||
|
var value = rune.Value;
|
||||||
|
|
||||||
|
// Common emoji ranges
|
||||||
|
if (value >= 0x1F600 && value <= 0x1F64F) return true; // Emoticons
|
||||||
|
if (value >= 0x1F300 && value <= 0x1F5FF) return true; // Misc Symbols & Pictographs
|
||||||
|
if (value >= 0x1F680 && value <= 0x1F6FF) return true; // Transport & Map
|
||||||
|
if (value >= 0x1F900 && value <= 0x1F9FF) return true; // Supplemental Symbols
|
||||||
|
if (value >= 0x1FA00 && value <= 0x1FA6F) return true; // Chess Symbols
|
||||||
|
if (value >= 0x1FA70 && value <= 0x1FAFF) return true; // Symbols Extended-A
|
||||||
|
if (value >= 0x2600 && value <= 0x26FF) return true; // Misc Symbols
|
||||||
|
if (value >= 0x2700 && value <= 0x27BF) return true; // Dingbats
|
||||||
|
if (value >= 0xFE00 && value <= 0xFE0F) return true; // Variation Selectors
|
||||||
|
if (value >= 0x200D && value <= 0x200D) return true; // ZWJ
|
||||||
|
if (value >= 0x1F1E0 && value <= 0x1F1FF) return true; // Regional Indicators (flags)
|
||||||
|
if (value >= 0x231A && value <= 0x23F3) return true; // Misc Technical (watch, hourglass)
|
||||||
|
if (value >= 0x2934 && value <= 0x2935) return true; // Arrows
|
||||||
|
if (value >= 0x25AA && value <= 0x25FE) return true; // Geometric Shapes
|
||||||
|
if (value >= 0x2B05 && value <= 0x2B55) return true; // Misc Symbols & Arrows
|
||||||
|
if (value >= 0x3030 && value <= 0x303D) return true; // CJK Symbols
|
||||||
|
if (value == 0x00A9 || value == 0x00AE) return true; // © ®
|
||||||
|
if (value == 0x2122) return true; // ™
|
||||||
|
if (value >= 0x1F000 && value <= 0x1F02F) return true; // Mahjong & Dominos
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract the base emoji string (first non-modifier, non-ZWJ rune) for lookup.
|
||||||
|
/// </summary>
|
||||||
|
private static string? GetBaseEmoji(string grapheme)
|
||||||
|
{
|
||||||
|
foreach (var rune in grapheme.EnumerateRunes())
|
||||||
|
{
|
||||||
|
// Skip ZWJ, variation selectors, skin tone modifiers
|
||||||
|
if (rune.Value == 0x200D) continue;
|
||||||
|
if (rune.Value >= 0xFE00 && rune.Value <= 0xFE0F) continue;
|
||||||
|
if (rune.Value >= 0x1F3FB && rune.Value <= 0x1F3FF) continue;
|
||||||
|
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
return rune.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common emoji → shortcode mapping (display-only, covers most frequently used emoji)
|
||||||
|
private static readonly Dictionary<string, string> EmojiShortcodes = new()
|
||||||
|
{
|
||||||
|
// Smileys & Emotion
|
||||||
|
["\U0001F600"] = ":grinning:",
|
||||||
|
["\U0001F601"] = ":grin:",
|
||||||
|
["\U0001F602"] = ":joy:",
|
||||||
|
["\U0001F603"] = ":smiley:",
|
||||||
|
["\U0001F604"] = ":smile:",
|
||||||
|
["\U0001F605"] = ":sweat_smile:",
|
||||||
|
["\U0001F606"] = ":laughing:",
|
||||||
|
["\U0001F607"] = ":innocent:",
|
||||||
|
["\U0001F608"] = ":smiling_imp:",
|
||||||
|
["\U0001F609"] = ":wink:",
|
||||||
|
["\U0001F60A"] = ":blush:",
|
||||||
|
["\U0001F60B"] = ":yum:",
|
||||||
|
["\U0001F60C"] = ":relieved:",
|
||||||
|
["\U0001F60D"] = ":heart_eyes:",
|
||||||
|
["\U0001F60E"] = ":sunglasses:",
|
||||||
|
["\U0001F60F"] = ":smirk:",
|
||||||
|
["\U0001F610"] = ":neutral_face:",
|
||||||
|
["\U0001F611"] = ":expressionless:",
|
||||||
|
["\U0001F612"] = ":unamused:",
|
||||||
|
["\U0001F613"] = ":sweat:",
|
||||||
|
["\U0001F614"] = ":pensive:",
|
||||||
|
["\U0001F615"] = ":confused:",
|
||||||
|
["\U0001F616"] = ":confounded:",
|
||||||
|
["\U0001F617"] = ":kissing:",
|
||||||
|
["\U0001F618"] = ":kissing_heart:",
|
||||||
|
["\U0001F619"] = ":kissing_smiling_eyes:",
|
||||||
|
["\U0001F61A"] = ":kissing_closed_eyes:",
|
||||||
|
["\U0001F61B"] = ":stuck_out_tongue:",
|
||||||
|
["\U0001F61C"] = ":stuck_out_tongue_winking_eye:",
|
||||||
|
["\U0001F61D"] = ":stuck_out_tongue_closed_eyes:",
|
||||||
|
["\U0001F61E"] = ":disappointed:",
|
||||||
|
["\U0001F61F"] = ":worried:",
|
||||||
|
["\U0001F620"] = ":angry:",
|
||||||
|
["\U0001F621"] = ":rage:",
|
||||||
|
["\U0001F622"] = ":cry:",
|
||||||
|
["\U0001F623"] = ":persevere:",
|
||||||
|
["\U0001F624"] = ":triumph:",
|
||||||
|
["\U0001F625"] = ":disappointed_relieved:",
|
||||||
|
["\U0001F626"] = ":frowning:",
|
||||||
|
["\U0001F627"] = ":anguished:",
|
||||||
|
["\U0001F628"] = ":fearful:",
|
||||||
|
["\U0001F629"] = ":weary:",
|
||||||
|
["\U0001F62A"] = ":sleepy:",
|
||||||
|
["\U0001F62B"] = ":tired_face:",
|
||||||
|
["\U0001F62C"] = ":grimacing:",
|
||||||
|
["\U0001F62D"] = ":sob:",
|
||||||
|
["\U0001F62E"] = ":open_mouth:",
|
||||||
|
["\U0001F62F"] = ":hushed:",
|
||||||
|
["\U0001F630"] = ":cold_sweat:",
|
||||||
|
["\U0001F631"] = ":scream:",
|
||||||
|
["\U0001F632"] = ":astonished:",
|
||||||
|
["\U0001F633"] = ":flushed:",
|
||||||
|
["\U0001F634"] = ":sleeping:",
|
||||||
|
["\U0001F635"] = ":dizzy_face:",
|
||||||
|
["\U0001F636"] = ":no_mouth:",
|
||||||
|
["\U0001F637"] = ":mask:",
|
||||||
|
["\U0001F641"] = ":slightly_frowning_face:",
|
||||||
|
["\U0001F642"] = ":slightly_smiling_face:",
|
||||||
|
["\U0001F643"] = ":upside_down_face:",
|
||||||
|
["\U0001F644"] = ":roll_eyes:",
|
||||||
|
["\U0001F910"] = ":zipper_mouth:",
|
||||||
|
["\U0001F911"] = ":money_mouth:",
|
||||||
|
["\U0001F912"] = ":thermometer_face:",
|
||||||
|
["\U0001F913"] = ":nerd:",
|
||||||
|
["\U0001F914"] = ":thinking:",
|
||||||
|
["\U0001F915"] = ":head_bandage:",
|
||||||
|
["\U0001F920"] = ":cowboy:",
|
||||||
|
["\U0001F921"] = ":clown:",
|
||||||
|
["\U0001F922"] = ":nauseated:",
|
||||||
|
["\U0001F923"] = ":rofl:",
|
||||||
|
["\U0001F924"] = ":drooling:",
|
||||||
|
["\U0001F925"] = ":lying:",
|
||||||
|
["\U0001F929"] = ":star_struck:",
|
||||||
|
["\U0001F92A"] = ":zany:",
|
||||||
|
["\U0001F92B"] = ":shushing:",
|
||||||
|
["\U0001F92C"] = ":cursing:",
|
||||||
|
["\U0001F92D"] = ":hand_over_mouth:",
|
||||||
|
["\U0001F92E"] = ":vomiting:",
|
||||||
|
["\U0001F92F"] = ":exploding_head:",
|
||||||
|
["\U0001F970"] = ":smiling_face_with_hearts:",
|
||||||
|
["\U0001F971"] = ":yawning:",
|
||||||
|
["\U0001F972"] = ":smiling_with_tear:",
|
||||||
|
["\U0001F973"] = ":partying:",
|
||||||
|
["\U0001F974"] = ":woozy:",
|
||||||
|
["\U0001F975"] = ":hot_face:",
|
||||||
|
["\U0001F976"] = ":cold_face:",
|
||||||
|
["\U0001F979"] = ":holding_back_tears:",
|
||||||
|
["\U0001F97A"] = ":pleading:",
|
||||||
|
["\U0001FAE0"] = ":melting:",
|
||||||
|
["\U0001FAE1"] = ":saluting:",
|
||||||
|
["\U0001FAE2"] = ":face_with_open_eyes_hand_over_mouth:",
|
||||||
|
["\U0001FAE3"] = ":face_with_peeking_eye:",
|
||||||
|
["\U0001FAE4"] = ":face_with_diagonal_mouth:",
|
||||||
|
|
||||||
|
// Gestures
|
||||||
|
["\U0001F44D"] = ":+1:",
|
||||||
|
["\U0001F44E"] = ":-1:",
|
||||||
|
["\U0001F44B"] = ":wave:",
|
||||||
|
["\U0001F44C"] = ":ok_hand:",
|
||||||
|
["\U0001F44F"] = ":clap:",
|
||||||
|
["\U0001F44A"] = ":fist:",
|
||||||
|
["\U0001F91D"] = ":handshake:",
|
||||||
|
["\U0001F91E"] = ":crossed_fingers:",
|
||||||
|
["\U0001F91F"] = ":love_you:",
|
||||||
|
["\U0001F918"] = ":metal:",
|
||||||
|
["\U0001F919"] = ":call_me:",
|
||||||
|
["\U0001F590"] = ":raised_hand:",
|
||||||
|
["\U0001F4AA"] = ":muscle:",
|
||||||
|
["\U0001F926"] = ":facepalm:",
|
||||||
|
["\U0001F937"] = ":shrug:",
|
||||||
|
["\U0001F64F"] = ":pray:",
|
||||||
|
["\U0001F64C"] = ":raised_hands:",
|
||||||
|
["\U0001F64B"] = ":raising_hand:",
|
||||||
|
|
||||||
|
// Hearts & Symbols
|
||||||
|
["\u2764"] = "<3",
|
||||||
|
["\U0001F494"] = "</3",
|
||||||
|
["\U0001F495"] = ":two_hearts:",
|
||||||
|
["\U0001F496"] = ":sparkling_heart:",
|
||||||
|
["\U0001F497"] = ":heartpulse:",
|
||||||
|
["\U0001F498"] = ":cupid:",
|
||||||
|
["\U0001F499"] = ":blue_heart:",
|
||||||
|
["\U0001F49A"] = ":green_heart:",
|
||||||
|
["\U0001F49B"] = ":yellow_heart:",
|
||||||
|
["\U0001F49C"] = ":purple_heart:",
|
||||||
|
["\U0001F49D"] = ":gift_heart:",
|
||||||
|
["\U0001F49E"] = ":revolving_hearts:",
|
||||||
|
["\U0001F49F"] = ":heart_decoration:",
|
||||||
|
["\U0001F90D"] = ":white_heart:",
|
||||||
|
["\U0001F90E"] = ":brown_heart:",
|
||||||
|
["\U0001F5A4"] = ":black_heart:",
|
||||||
|
["\U0001F9E1"] = ":orange_heart:",
|
||||||
|
|
||||||
|
// Objects & Nature
|
||||||
|
["\U0001F525"] = ":fire:",
|
||||||
|
["\U0001F4A9"] = ":poop:",
|
||||||
|
["\U0001F480"] = ":skull:",
|
||||||
|
["\U0001F47B"] = ":ghost:",
|
||||||
|
["\U0001F47D"] = ":alien:",
|
||||||
|
["\U0001F916"] = ":robot:",
|
||||||
|
["\U0001F4AF"] = ":100:",
|
||||||
|
["\U0001F4A5"] = ":boom:",
|
||||||
|
["\U0001F4A4"] = ":zzz:",
|
||||||
|
["\U0001F4A2"] = ":anger:",
|
||||||
|
["\U0001F4AC"] = ":speech_balloon:",
|
||||||
|
["\U0001F440"] = ":eyes:",
|
||||||
|
["\U0001F3B5"] = ":musical_note:",
|
||||||
|
["\U0001F3B6"] = ":notes:",
|
||||||
|
["\U0001F389"] = ":tada:",
|
||||||
|
["\U0001F38A"] = ":confetti:",
|
||||||
|
["\U0001F381"] = ":gift:",
|
||||||
|
["\U0001F3C6"] = ":trophy:",
|
||||||
|
["\U0001F4B0"] = ":money_bag:",
|
||||||
|
["\U0001F4BB"] = ":computer:",
|
||||||
|
["\U0001F4F1"] = ":phone:",
|
||||||
|
["\U0001F4E7"] = ":email:",
|
||||||
|
["\U0001F511"] = ":key:",
|
||||||
|
["\U0001F512"] = ":lock:",
|
||||||
|
["\U0001F513"] = ":unlock:",
|
||||||
|
["\U0001F6A8"] = ":rotating_light:",
|
||||||
|
["\U0001F6AB"] = ":no_entry:",
|
||||||
|
|
||||||
|
// Animals
|
||||||
|
["\U0001F436"] = ":dog:",
|
||||||
|
["\U0001F431"] = ":cat:",
|
||||||
|
["\U0001F42D"] = ":mouse:",
|
||||||
|
["\U0001F430"] = ":rabbit:",
|
||||||
|
["\U0001F43B"] = ":bear:",
|
||||||
|
["\U0001F427"] = ":penguin:",
|
||||||
|
["\U0001F41D"] = ":bee:",
|
||||||
|
["\U0001F40D"] = ":snake:",
|
||||||
|
["\U0001F422"] = ":turtle:",
|
||||||
|
|
||||||
|
// Food & Drink
|
||||||
|
["\U0001F355"] = ":pizza:",
|
||||||
|
["\U0001F354"] = ":hamburger:",
|
||||||
|
["\U0001F37A"] = ":beer:",
|
||||||
|
["\U0001F377"] = ":wine:",
|
||||||
|
["\U0001F370"] = ":cake:",
|
||||||
|
["\u2615"] = ":coffee:",
|
||||||
|
["\U0001F382"] = ":birthday:",
|
||||||
|
|
||||||
|
// Misc symbols (BMP)
|
||||||
|
["\u2705"] = ":white_check_mark:",
|
||||||
|
["\u274C"] = ":x:",
|
||||||
|
["\u274E"] = ":negative_squared_cross_mark:",
|
||||||
|
["\u2714"] = ":heavy_check_mark:",
|
||||||
|
["\u2716"] = ":heavy_multiplication_x:",
|
||||||
|
["\u26A0"] = ":warning:",
|
||||||
|
["\u2B50"] = ":star:",
|
||||||
|
["\u2728"] = ":sparkles:",
|
||||||
|
["\u267B"] = ":recycle:",
|
||||||
|
["\u2611"] = ":ballot_box_with_check:",
|
||||||
|
["\u23F0"] = ":alarm_clock:",
|
||||||
|
["\u231A"] = ":watch:",
|
||||||
|
["\u231B"] = ":hourglass:",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Configuration;
|
using Terminal.Gui.Configuration;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
using Terminal.Gui.Input;
|
using Terminal.Gui.Input;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
@@ -21,14 +23,26 @@ public sealed class MainWindow : Runnable
|
|||||||
private readonly ListView _messageList;
|
private readonly ListView _messageList;
|
||||||
private readonly TextView _inputField;
|
private readonly TextView _inputField;
|
||||||
private readonly FrameView _chatFrame;
|
private readonly FrameView _chatFrame;
|
||||||
|
private readonly FrameView _inputFrame;
|
||||||
private readonly Label _statusLabel;
|
private readonly Label _statusLabel;
|
||||||
private readonly Label _topicLabel;
|
private readonly Label _topicLabel;
|
||||||
private MenuBar _menuBar;
|
private MenuBar _menuBar;
|
||||||
|
|
||||||
|
// Online users panel
|
||||||
|
private readonly FrameView _usersFrame;
|
||||||
|
private readonly ListView _usersList;
|
||||||
|
private readonly UserListSource _usersListSource;
|
||||||
|
private bool _usersPanelVisible = true;
|
||||||
|
private const int UsersPanelWidth = 22;
|
||||||
|
private static readonly Key F2Key = Key.F2;
|
||||||
|
|
||||||
|
internal static readonly string AppVersion =
|
||||||
|
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
||||||
|
|
||||||
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
||||||
private static readonly Key EnterKey = Key.Enter;
|
private static readonly Key EnterKey = Key.Enter;
|
||||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||||
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
private static readonly Key AltQKey = Key.Q.WithAlt;
|
||||||
private static readonly Key TabKey = Key.Tab;
|
private static readonly Key TabKey = Key.Tab;
|
||||||
|
|
||||||
// Available slash commands for Tab autocomplete
|
// Available slash commands for Tab autocomplete
|
||||||
@@ -36,13 +50,15 @@ public sealed class MainWindow : Runnable
|
|||||||
[
|
[
|
||||||
"/status", "/nick", "/color", "/theme", "/send",
|
"/status", "/nick", "/color", "/theme", "/send",
|
||||||
"/avatar", "/profile", "/servers", "/join", "/leave",
|
"/avatar", "/profile", "/servers", "/join", "/leave",
|
||||||
"/topic", "/users", "/quit", "/help"
|
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||||
|
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
private readonly Dictionary<string, int> _channelUnread = [];
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||||
|
private readonly ChannelListSource _channelListSource;
|
||||||
private string _currentChannel = string.Empty;
|
private string _currentChannel = string.Empty;
|
||||||
private string _currentUser = string.Empty;
|
private string _currentUser = string.Empty;
|
||||||
private int _lastChatWidth;
|
private int _lastChatWidth;
|
||||||
@@ -92,6 +108,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnCreateChannelRequested;
|
public event Action? OnCreateChannelRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to delete the current channel.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnDeleteChannelRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app)
|
public MainWindow(IApplication app)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
@@ -107,7 +128,7 @@ public sealed class MainWindow : Runnable
|
|||||||
Title = "Channels",
|
Title = "Channels",
|
||||||
X = 0,
|
X = 0,
|
||||||
Y = 1, // below menu bar
|
Y = 1, // below menu bar
|
||||||
Width = 25,
|
Width = 22,
|
||||||
Height = Dim.Fill(1) // leave room for status bar
|
Height = Dim.Fill(1) // leave room for status bar
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +139,8 @@ public sealed class MainWindow : Runnable
|
|||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(),
|
||||||
Height = Dim.Fill()
|
Height = Dim.Fill()
|
||||||
};
|
};
|
||||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
_channelListSource = new ChannelListSource();
|
||||||
|
_channelList.Source = _channelListSource;
|
||||||
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
||||||
channelsFrame.Add(_channelList);
|
channelsFrame.Add(_channelList);
|
||||||
Add(channelsFrame);
|
Add(channelsFrame);
|
||||||
@@ -127,9 +149,9 @@ public sealed class MainWindow : Runnable
|
|||||||
_topicLabel = new Label
|
_topicLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "",
|
Text = "",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = 1,
|
Y = 1,
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = 1,
|
Height = 1,
|
||||||
Visible = false
|
Visible = false
|
||||||
};
|
};
|
||||||
@@ -139,9 +161,9 @@ public sealed class MainWindow : Runnable
|
|||||||
_chatFrame = new FrameView
|
_chatFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Chat",
|
Title = "Chat",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = 1, // below menu bar (shifts to 2 when topic is visible)
|
Y = 1, // below menu bar (shifts to 2 when topic is visible)
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = Dim.Fill(6) // leave room for input area and status bar
|
Height = Dim.Fill(6) // leave room for input area and status bar
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,12 +179,12 @@ public sealed class MainWindow : Runnable
|
|||||||
Add(_chatFrame);
|
Add(_chatFrame);
|
||||||
|
|
||||||
// Bottom input area
|
// Bottom input area
|
||||||
var inputFrame = new FrameView
|
_inputFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)",
|
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = Pos.Bottom(_chatFrame),
|
Y = Pos.Bottom(_chatFrame),
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = 5
|
Height = 5
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,8 +197,31 @@ public sealed class MainWindow : Runnable
|
|||||||
WordWrap = true
|
WordWrap = true
|
||||||
};
|
};
|
||||||
_inputField.KeyDown += OnInputKeyDown;
|
_inputField.KeyDown += OnInputKeyDown;
|
||||||
inputFrame.Add(_inputField);
|
_inputField.ContentsChanged += OnInputContentsChanged;
|
||||||
Add(inputFrame);
|
_inputFrame.Add(_inputField);
|
||||||
|
Add(_inputFrame);
|
||||||
|
|
||||||
|
// Right panel - online users
|
||||||
|
_usersFrame = new FrameView
|
||||||
|
{
|
||||||
|
Title = "Users",
|
||||||
|
X = Pos.AnchorEnd(UsersPanelWidth),
|
||||||
|
Y = 1,
|
||||||
|
Width = UsersPanelWidth,
|
||||||
|
Height = Dim.Fill(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
_usersList = new ListView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill()
|
||||||
|
};
|
||||||
|
_usersListSource = new UserListSource();
|
||||||
|
_usersList.Source = _usersListSource;
|
||||||
|
_usersFrame.Add(_usersList);
|
||||||
|
Add(_usersFrame);
|
||||||
|
|
||||||
// Status bar at the very bottom
|
// Status bar at the very bottom
|
||||||
_statusLabel = new Label
|
_statusLabel = new Label
|
||||||
@@ -198,7 +243,7 @@ public sealed class MainWindow : Runnable
|
|||||||
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
||||||
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
|
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
|
||||||
|
|
||||||
// Window-level key handling for Ctrl+C (quit)
|
// Window-level key handling for Alt+Q (quit), F2 (toggle users panel)
|
||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +310,11 @@ public sealed class MainWindow : Runnable
|
|||||||
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
||||||
new Line(),
|
new Line(),
|
||||||
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
||||||
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
|
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
|
||||||
|
new Line(),
|
||||||
|
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty),
|
||||||
|
new Line(),
|
||||||
|
new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty)
|
||||||
}),
|
}),
|
||||||
new MenuBarItem("_User", allUserItems)
|
new MenuBarItem("_User", allUserItems)
|
||||||
]);
|
]);
|
||||||
@@ -333,13 +382,38 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
else if (e.KeyCode == CtrlCKey.KeyCode)
|
else if (e.KeyCode == AltQKey.KeyCode)
|
||||||
{
|
{
|
||||||
_app.RequestStop();
|
_app.RequestStop();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool _suppressEmojiReplace;
|
||||||
|
|
||||||
|
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEmojiReplace)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text = _inputField.Text;
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var replaced = EmojiHelper.ReplaceEmoji(text);
|
||||||
|
if (replaced == text)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Calculate where cursor should land after replacement
|
||||||
|
var lengthDelta = replaced.Length - text.Length;
|
||||||
|
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
|
||||||
|
|
||||||
|
_suppressEmojiReplace = true;
|
||||||
|
_inputField.Text = replaced;
|
||||||
|
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
||||||
|
_suppressEmojiReplace = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tab-complete slash commands in the input field.
|
/// Tab-complete slash commands in the input field.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -386,12 +460,16 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
private void OnWindowKeyDown(object? sender, Key e)
|
private void OnWindowKeyDown(object? sender, Key e)
|
||||||
{
|
{
|
||||||
// Ctrl+C quits from anywhere
|
if (e.KeyCode == AltQKey.KeyCode)
|
||||||
if (e.KeyCode == CtrlCKey.KeyCode)
|
|
||||||
{
|
{
|
||||||
_app.RequestStop();
|
_app.RequestStop();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
else if (e.KeyCode == F2Key.KeyCode)
|
||||||
|
{
|
||||||
|
ToggleUsersPanel();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -430,19 +508,33 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void AddSystemMessage(string channelName, string text)
|
public void AddSystemMessage(string channelName, string text)
|
||||||
{
|
{
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {text}", ChatColors.SystemAttr)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
{
|
{
|
||||||
messages = [];
|
messages = [];
|
||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
messages.Add(new ChatLine(segments));
|
|
||||||
|
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||||
|
var textLines = text.Split('\n');
|
||||||
|
|
||||||
|
// First line gets timestamp prefix
|
||||||
|
messages.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Continuation lines are indented to align
|
||||||
|
var indent = new string(' ', $"[{time}] ** ".Length);
|
||||||
|
for (int i = 1; i < textLines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = textLines[i].TrimEnd('\r');
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
messages.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new($"{indent}{line}", ChatColors.SystemAttr)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
{
|
{
|
||||||
@@ -475,6 +567,32 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all lines associated with a specific message ID.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveMessage(string channelName, Guid messageId)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.RemoveAll(l => l.MessageId == messageId);
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear all messages from a specific channel.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearChannelMessages(string channelName)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.Clear();
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -492,6 +610,30 @@ public sealed class MainWindow : Runnable
|
|||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
||||||
|
/// </summary>
|
||||||
|
public void EnsureChannelInList(string channelName)
|
||||||
|
{
|
||||||
|
if (_channelNames.Contains(channelName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_channelNames.Add(channelName);
|
||||||
|
if (!_channelMessages.ContainsKey(channelName))
|
||||||
|
_channelMessages[channelName] = [];
|
||||||
|
RefreshChannelList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove a channel from the left panel list.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveChannel(string channelName)
|
||||||
|
{
|
||||||
|
_channelNames.Remove(channelName);
|
||||||
|
_channelTopics.Remove(channelName);
|
||||||
|
RefreshChannelList();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update the topic for a specific channel.
|
/// Update the topic for a specific channel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -515,9 +657,9 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void UpdateStatusBar(string status)
|
public void UpdateStatusBar(string status)
|
||||||
{
|
{
|
||||||
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}";
|
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}";
|
||||||
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}";
|
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}";
|
||||||
_statusLabel.Text = $" {status}{userPart}{channelPart}";
|
_statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -585,10 +727,14 @@ public sealed class MainWindow : Runnable
|
|||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
_currentChannel = string.Empty;
|
_currentChannel = string.Empty;
|
||||||
_currentUser = string.Empty;
|
_currentUser = string.Empty;
|
||||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
_channelListSource.Update([], [], string.Empty);
|
||||||
|
_channelList.Source = _channelListSource;
|
||||||
_chatFrame.Title = "Chat";
|
_chatFrame.Title = "Chat";
|
||||||
_topicLabel.Visible = false;
|
_topicLabel.Visible = false;
|
||||||
_chatFrame.Y = 1;
|
_chatFrame.Y = 1;
|
||||||
|
_usersListSource.Update([]);
|
||||||
|
_usersList.Source = _usersListSource;
|
||||||
|
_usersFrame.Title = "Users";
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,13 +786,8 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
var displayNames = _channelNames.Select(name =>
|
_channelListSource.Update(_channelNames, _channelUnread, _currentChannel);
|
||||||
{
|
_channelList.Source = _channelListSource;
|
||||||
_channelUnread.TryGetValue(name, out var unread);
|
|
||||||
return unread > 0 ? $"#{name} ({unread})" : $"#{name}";
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
_channelList.SetSource(new ObservableCollection<string>(displayNames));
|
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
var idx = _channelNames.IndexOf(_currentChannel);
|
var idx = _channelNames.IndexOf(_currentChannel);
|
||||||
@@ -673,11 +814,66 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjusts widths of chat, topic, and input frames based on users panel visibility.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateLayout()
|
||||||
|
{
|
||||||
|
var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0;
|
||||||
|
_chatFrame.Width = Dim.Fill(rightMargin);
|
||||||
|
_topicLabel.Width = Dim.Fill(rightMargin);
|
||||||
|
_inputFrame.Width = Dim.Fill(rightMargin);
|
||||||
|
_usersFrame.Visible = _usersPanelVisible;
|
||||||
|
SetNeedsDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle the online users panel visibility (F2).
|
||||||
|
/// </summary>
|
||||||
|
public void ToggleUsersPanel()
|
||||||
|
{
|
||||||
|
_usersPanelVisible = !_usersPanelVisible;
|
||||||
|
UpdateLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the online users list display.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateOnlineUsers(List<UserPresenceDto> users)
|
||||||
|
{
|
||||||
|
var displayItems = users.Select(u =>
|
||||||
|
{
|
||||||
|
var statusIcon = u.Status switch
|
||||||
|
{
|
||||||
|
UserStatus.Online => "\u25cf", // ●
|
||||||
|
UserStatus.Away => "\u25cb", // ○
|
||||||
|
UserStatus.DoNotDisturb => "\u25d0", // ◐
|
||||||
|
UserStatus.Invisible => "\u25cc", // ◌
|
||||||
|
_ => " "
|
||||||
|
};
|
||||||
|
var name = u.DisplayName ?? u.Username;
|
||||||
|
var roleTag = u.Role switch
|
||||||
|
{
|
||||||
|
ServerRole.Owner => "\u2605", // ★
|
||||||
|
ServerRole.Admin => "\u2666", // ♦
|
||||||
|
ServerRole.Mod => "\u2740", // ❀
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var text = $"{statusIcon} {roleTag}{name}";
|
||||||
|
var nameColor = ColorHelper.ParseHexColor(u.NicknameColor);
|
||||||
|
return (text, nameColor);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_usersListSource.Update(displayItems);
|
||||||
|
_usersList.Source = _usersListSource;
|
||||||
|
_usersFrame.Title = $"Users ({users.Count})";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a message DTO into one or more display lines based on its MessageType.
|
/// Format a message DTO into one or more display lines based on its MessageType.
|
||||||
/// Timestamps are dimmed and sender names are colored.
|
/// Timestamps are dimmed and sender names are colored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<ChatLine> FormatMessage(MessageDto message)
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
{
|
{
|
||||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||||
var senderName = message.SenderUsername + ":";
|
var senderName = message.SenderUsername + ":";
|
||||||
@@ -694,10 +890,10 @@ public sealed class MainWindow : Runnable
|
|||||||
{
|
{
|
||||||
foreach (var artLine in message.Content.Split('\n'))
|
foreach (var artLine in message.Content.Split('\n'))
|
||||||
{
|
{
|
||||||
// Parse ANSI color codes from colored ASCII art
|
// Parse color tags from colored ASCII art
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
if (trimmed.Contains('\x1b'))
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
lines.Add(ChatLine.FromAnsi(" " + trimmed));
|
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
||||||
else
|
else
|
||||||
lines.Add(new ChatLine($" {trimmed}"));
|
lines.Add(new ChatLine($" {trimmed}"));
|
||||||
}
|
}
|
||||||
@@ -712,18 +908,43 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
case MessageType.Text:
|
case MessageType.Text:
|
||||||
default:
|
default:
|
||||||
var contentLines = message.Content.Split('\n');
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
|
var contentLines = displayContent.Split('\n');
|
||||||
var firstLine = contentLines[0].TrimEnd('\r');
|
var firstLine = contentLines[0].TrimEnd('\r');
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, $" {firstLine}"));
|
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
||||||
// Continuation lines indented to align with first line's content
|
// Continuation lines indented to align with first line's content
|
||||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||||
for (int i = 1; i < contentLines.Length; i++)
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
{
|
{
|
||||||
lines.Add(new ChatLine($"{indent}{contentLines[i].TrimEnd('\r')}"));
|
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
||||||
|
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render link embeds if present
|
||||||
|
if (message.Embeds is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag all lines with the message ID for deletion support
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.MessageId = message.Id;
|
||||||
|
|
||||||
|
// Check for @mention of current user
|
||||||
|
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||||
|
{
|
||||||
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
|
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.IsMention = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,4 +961,98 @@ public sealed class MainWindow : Runnable
|
|||||||
};
|
};
|
||||||
return new ChatLine(segments);
|
return new ChatLine(segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a chat line with @mention highlighting in the suffix text.
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
};
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(suffix));
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format a link embed as indented chat lines with a left border bar,
|
||||||
|
/// text at full width, and optional icon below (preview image).
|
||||||
|
/// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
||||||
|
{
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
const string border = "\u258f "; // ▏ + space
|
||||||
|
const int borderCols = 2;
|
||||||
|
int indentCols = indent.GetColumns();
|
||||||
|
int textWidth = chatWidth - indentCols - borderCols;
|
||||||
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
|
// Helper: create a bordered text line
|
||||||
|
void AddTextLine(string text, Attribute? color)
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new ChatSegment(indent, null),
|
||||||
|
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
||||||
|
new ChatSegment(text, color)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site name
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
|
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
{
|
||||||
|
foreach (var wrapped in WordWrap(embed.Title, textWidth))
|
||||||
|
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description (word-wrapped at full available width)
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
foreach (var wrapped in WordWrap(embed.Description, textWidth))
|
||||||
|
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple word-wrap: splits text into lines that fit within maxCols display columns.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> WordWrap(string text, int maxCols)
|
||||||
|
{
|
||||||
|
if (maxCols <= 0)
|
||||||
|
return [text];
|
||||||
|
|
||||||
|
var result = new List<string>();
|
||||||
|
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var currentLine = "";
|
||||||
|
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
|
||||||
|
if (candidate.GetColumns() <= maxCols)
|
||||||
|
{
|
||||||
|
currentLine = candidate;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (currentLine.Length > 0)
|
||||||
|
result.Add(currentLine);
|
||||||
|
// If a single word exceeds maxCols, just add it as-is
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine.Length > 0)
|
||||||
|
result.Add(currentLine);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace EchoHub.Client.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the profile edit dialog.
|
/// Result returned from the profile edit dialog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
|
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath, bool? NotificationSoundEnabled, byte? NotificationVolume);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
||||||
@@ -19,11 +19,11 @@ public sealed class ProfileEditDialog
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shows the profile edit dialog and returns the result, or null if cancelled.
|
/// Shows the profile edit dialog and returns the result, or null if cancelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor)
|
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor, bool notificationSoundEnabled = false, byte notificationVolume = 30)
|
||||||
{
|
{
|
||||||
ProfileEditResult? result = null;
|
ProfileEditResult? result = null;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
|
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 };
|
||||||
|
|
||||||
// Display Name
|
// Display Name
|
||||||
var nameLabel = new Label
|
var nameLabel = new Label
|
||||||
@@ -102,20 +102,99 @@ public sealed class ProfileEditDialog
|
|||||||
UpdateColorPreview(colorPreview, colorField.Text);
|
UpdateColorPreview(colorPreview, colorField.Text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
var avatarLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Avatar:",
|
||||||
|
X = 1,
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
var avatarField = new TextField
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
X = 17,
|
||||||
|
Y = 10,
|
||||||
|
Width = Dim.Fill(12)
|
||||||
|
};
|
||||||
|
var browseButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Browse",
|
||||||
|
X = Pos.AnchorEnd(10),
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
var avatarHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "(file path or URL)",
|
||||||
|
X = 17,
|
||||||
|
Y = 11
|
||||||
|
};
|
||||||
|
avatarHintLabel.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||||
|
});
|
||||||
|
|
||||||
|
browseButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
var openDialog = new OpenDialog
|
||||||
|
{
|
||||||
|
Title = "Select Avatar Image",
|
||||||
|
OpenMode = OpenMode.File,
|
||||||
|
};
|
||||||
|
app.Run(openDialog);
|
||||||
|
if (openDialog.FilePaths.Count > 0)
|
||||||
|
{
|
||||||
|
avatarField.Text = openDialog.FilePaths[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notification Sound
|
||||||
|
var notifCheckbox = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Notification sound on @mention",
|
||||||
|
X = 1,
|
||||||
|
Y = 13,
|
||||||
|
Value = notificationSoundEnabled ? CheckState.Checked : CheckState.UnChecked
|
||||||
|
};
|
||||||
|
|
||||||
|
var volumeLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Volume:",
|
||||||
|
X = 1,
|
||||||
|
Y = 15
|
||||||
|
};
|
||||||
|
var volumeField = new TextField
|
||||||
|
{
|
||||||
|
Text = notificationVolume.ToString(),
|
||||||
|
X = 17,
|
||||||
|
Y = 15,
|
||||||
|
Width = 6
|
||||||
|
};
|
||||||
|
var volumeHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "(0-100)",
|
||||||
|
X = 24,
|
||||||
|
Y = 15
|
||||||
|
};
|
||||||
|
volumeHintLabel.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||||
|
});
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
var saveButton = new Button
|
var saveButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Save",
|
Text = "Save",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 10,
|
X = Pos.Center() - 10,
|
||||||
Y = 10
|
Y = 18
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 5,
|
X = Pos.Center() + 5,
|
||||||
Y = 10
|
Y = 18
|
||||||
};
|
};
|
||||||
|
|
||||||
saveButton.Accepting += (s, e) =>
|
saveButton.Accepting += (s, e) =>
|
||||||
@@ -123,8 +202,10 @@ public sealed class ProfileEditDialog
|
|||||||
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
||||||
var bio = NullIfEmpty(bioField.Text?.Trim());
|
var bio = NullIfEmpty(bioField.Text?.Trim());
|
||||||
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
||||||
|
var avatarPath = NullIfEmpty(avatarField.Text?.Trim());
|
||||||
|
|
||||||
result = new ProfileEditResult(displayName, bio, nicknameColor);
|
byte? volume = byte.TryParse(volumeField.Text, out var v) ? Math.Min(v, (byte)100) : null;
|
||||||
|
result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath, notifCheckbox.Value == CheckState.Checked, volume);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -137,7 +218,10 @@ public sealed class ProfileEditDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
||||||
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
|
colorHintLabel, previewLabel, colorPreview,
|
||||||
|
avatarLabel, avatarField, browseButton, avatarHintLabel,
|
||||||
|
notifCheckbox, volumeLabel, volumeField, volumeHintLabel,
|
||||||
|
saveButton, cancelButton);
|
||||||
|
|
||||||
nameField.SetFocus();
|
nameField.SetFocus();
|
||||||
app.Run(dialog);
|
app.Run(dialog);
|
||||||
|
|||||||
@@ -140,12 +140,20 @@ public sealed class ProfileViewDialog
|
|||||||
dialog.Add(bioView);
|
dialog.Add(bioView);
|
||||||
row += 3;
|
row += 3;
|
||||||
|
|
||||||
// ASCII Avatar
|
// ASCII Avatar — render with color tags
|
||||||
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
|
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
|
||||||
{
|
{
|
||||||
row++;
|
row++;
|
||||||
var avatarLines = profile.AvatarAscii.Split('\n').Length;
|
var rawLines = profile.AvatarAscii.Split('\n');
|
||||||
var avatarHeight = Math.Min(avatarLines + 2, 6);
|
var avatarSource = new ChatListSource();
|
||||||
|
foreach (var line in rawLines)
|
||||||
|
{
|
||||||
|
avatarSource.Add(ChatLine.HasColorTags(line)
|
||||||
|
? ChatLine.FromColoredText(line)
|
||||||
|
: new ChatLine(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
var avatarHeight = Math.Min(rawLines.Length + 2, 24);
|
||||||
var avatarFrame = new FrameView
|
var avatarFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Avatar",
|
Title = "Avatar",
|
||||||
@@ -154,9 +162,22 @@ public sealed class ProfileViewDialog
|
|||||||
Width = Dim.Fill(2),
|
Width = Dim.Fill(2),
|
||||||
Height = avatarHeight
|
Height = avatarHeight
|
||||||
};
|
};
|
||||||
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 });
|
var avatarList = new ListView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill(),
|
||||||
|
Source = avatarSource
|
||||||
|
};
|
||||||
|
avatarFrame.Add(avatarList);
|
||||||
dialog.Add(avatarFrame);
|
dialog.Add(avatarFrame);
|
||||||
// Grow dialog to fit avatar
|
|
||||||
|
// Grow dialog to fit avatar + widen for art
|
||||||
|
var artWidth = rawLines.Max(l => ChatLine.HasColorTags(l)
|
||||||
|
? ChatLine.FromColoredText(l).TextLength
|
||||||
|
: l.Length);
|
||||||
|
dialog.Width = Math.Max(50, artWidth + 6);
|
||||||
dialog.Height = row + avatarHeight + 4;
|
dialog.Height = row + avatarHeight + 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,19 @@ public static class HubConstants
|
|||||||
{
|
{
|
||||||
public const string ChatHubPath = "/hubs/chat";
|
public const string ChatHubPath = "/hubs/chat";
|
||||||
public const string DefaultChannel = "general";
|
public const string DefaultChannel = "general";
|
||||||
public const int DefaultHistoryCount = 50;
|
public const int DefaultHistoryCount = 100;
|
||||||
public const int MaxMessageLength = 2000;
|
public const int MaxMessageLength = 2000;
|
||||||
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||||||
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
||||||
|
public const int MaxMessageNewlines = 30;
|
||||||
|
public const int MaxConsecutiveNewlines = 1;
|
||||||
public const int AsciiArtWidth = 80;
|
public const int AsciiArtWidth = 80;
|
||||||
public const int AsciiArtHeight = 40;
|
public const int AsciiArtHeight = 40;
|
||||||
|
public const int AsciiArtHeightHalfBlock = 80;
|
||||||
|
|
||||||
|
// Link embed constants
|
||||||
|
public const int EmbedMaxDescriptionLength = 500;
|
||||||
|
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
|
||||||
|
public const int EmbedFetchTimeoutSeconds = 5;
|
||||||
|
public const int EmbedMaxUrlsPerMessage = 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,10 @@ public interface IChatBroadcaster
|
|||||||
Task SendUserLeftAsync(string channelName, string username);
|
Task SendUserLeftAsync(string channelName, string username);
|
||||||
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
||||||
|
Task SendUserKickedAsync(string channelName, string username, string? reason);
|
||||||
|
Task SendUserBannedAsync(string username, string? reason);
|
||||||
|
Task SendMessageDeletedAsync(string channelName, Guid messageId);
|
||||||
|
Task SendChannelNukedAsync(string channelName);
|
||||||
Task SendErrorAsync(string connectionId, string message);
|
Task SendErrorAsync(string connectionId, string message);
|
||||||
|
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,10 @@ public interface IEchoHubClient
|
|||||||
Task UserLeft(string channelName, string username);
|
Task UserLeft(string channelName, string username);
|
||||||
Task ChannelUpdated(ChannelDto channel);
|
Task ChannelUpdated(ChannelDto channel);
|
||||||
Task UserStatusChanged(UserPresenceDto presence);
|
Task UserStatusChanged(UserPresenceDto presence);
|
||||||
|
Task UserKicked(string channelName, string username, string? reason);
|
||||||
|
Task UserBanned(string username, string? reason);
|
||||||
|
Task MessageDeleted(string channelName, Guid messageId);
|
||||||
|
Task ChannelNuked(string channelName);
|
||||||
|
Task ForceDisconnect(string reason);
|
||||||
Task Error(string message);
|
Task Error(string message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ public record MessageDto(
|
|||||||
MessageType Type,
|
MessageType Type,
|
||||||
string? AttachmentUrl,
|
string? AttachmentUrl,
|
||||||
string? AttachmentFileName,
|
string? AttachmentFileName,
|
||||||
DateTimeOffset SentAt);
|
DateTimeOffset SentAt,
|
||||||
|
List<EmbedDto>? Embeds = null);
|
||||||
|
|
||||||
public record ChannelDto(
|
public record ChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
string? Topic,
|
string? Topic,
|
||||||
|
bool IsPublic,
|
||||||
int MessageCount,
|
int MessageCount,
|
||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
@@ -30,8 +32,15 @@ public record UserDto(
|
|||||||
|
|
||||||
public record SendMessageRequest(string ChannelName, string Content);
|
public record SendMessageRequest(string ChannelName, string Content);
|
||||||
|
|
||||||
public record CreateChannelRequest(string Name, string? Topic = null);
|
public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true);
|
||||||
|
|
||||||
public record UpdateTopicRequest(string? Topic);
|
public record UpdateTopicRequest(string? Topic);
|
||||||
|
|
||||||
public record SendUrlRequest(string Url);
|
public record SendUrlRequest(string Url);
|
||||||
|
|
||||||
|
public record EmbedDto(
|
||||||
|
string? SiteName,
|
||||||
|
string? Title,
|
||||||
|
string? Description,
|
||||||
|
string? ImageAscii,
|
||||||
|
string Url);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record AssignRoleRequest(string Username, ServerRole Role);
|
||||||
|
public record MuteRequest(string? Reason = null, int? DurationMinutes = null);
|
||||||
|
public record BanRequest(string? Reason = null);
|
||||||
|
public record KickRequest(string? Reason = null);
|
||||||
@@ -11,6 +11,7 @@ public record UserProfileDto(
|
|||||||
string? AvatarAscii,
|
string? AvatarAscii,
|
||||||
UserStatus Status,
|
UserStatus Status,
|
||||||
string? StatusMessage,
|
string? StatusMessage,
|
||||||
|
ServerRole Role,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset LastSeenAt);
|
DateTimeOffset LastSeenAt);
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ public record UserPresenceDto(
|
|||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? NicknameColor,
|
string? NicknameColor,
|
||||||
UserStatus Status,
|
UserStatus Status,
|
||||||
string? StatusMessage);
|
string? StatusMessage,
|
||||||
|
ServerRole Role);
|
||||||
|
|
||||||
public record AvatarUploadResponse(string AvatarAscii);
|
public record AvatarUploadResponse(string AvatarAscii);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public class Channel
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Topic { get; set; }
|
public string? Topic { get; set; }
|
||||||
|
public bool IsPublic { get; set; } = true;
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public Guid CreatedByUserId { get; set; }
|
public Guid CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public class ChannelMembership
|
||||||
|
{
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public Guid ChannelId { get; set; }
|
||||||
|
public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ public class Message
|
|||||||
public MessageType Type { get; set; } = MessageType.Text;
|
public MessageType Type { get; set; } = MessageType.Text;
|
||||||
public string? AttachmentUrl { get; set; }
|
public string? AttachmentUrl { get; set; }
|
||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public string? EmbedJson { get; set; }
|
||||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public Guid ChannelId { get; set; }
|
public Guid ChannelId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public enum ServerRole
|
||||||
|
{
|
||||||
|
Member = 0,
|
||||||
|
Mod = 1,
|
||||||
|
Admin = 2,
|
||||||
|
Owner = 3
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ public class User
|
|||||||
public string? AvatarAscii { get; set; }
|
public string? AvatarAscii { get; set; }
|
||||||
public UserStatus Status { get; set; } = UserStatus.Online;
|
public UserStatus Status { get; set; } = UserStatus.Online;
|
||||||
public string? StatusMessage { get; set; }
|
public string? StatusMessage { get; set; }
|
||||||
|
public ServerRole Role { get; set; } = ServerRole.Member;
|
||||||
|
public bool IsMuted { get; set; }
|
||||||
|
public DateTimeOffset? MutedUntil { get; set; }
|
||||||
|
public bool IsBanned { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,20 @@ using EchoHub.Core.DTOs;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
public class IrcBroadcaster : IChatBroadcaster
|
||||||
{
|
{
|
||||||
|
private readonly IrcGatewayService _gateway;
|
||||||
|
|
||||||
|
public IrcBroadcaster(IrcGatewayService gateway)
|
||||||
|
{
|
||||||
|
_gateway = gateway;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
{
|
{
|
||||||
var lines = IrcMessageFormatter.FormatMessage(message);
|
var lines = IrcMessageFormatter.FormatMessage(message);
|
||||||
|
|
||||||
foreach (var conn in gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
// IRC convention: don't echo sender's own message
|
// IRC convention: don't echo sender's own message
|
||||||
if (conn.Nickname == message.SenderUsername)
|
if (conn.Nickname == message.SenderUsername)
|
||||||
@@ -22,7 +29,7 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
|||||||
|
|
||||||
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
foreach (var conn in gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
if (conn.ConnectionId == excludeConnectionId) continue;
|
if (conn.ConnectionId == excludeConnectionId) continue;
|
||||||
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
|
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
|
||||||
@@ -31,7 +38,7 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
|||||||
|
|
||||||
public async Task SendUserLeftAsync(string channelName, string username)
|
public async Task SendUserLeftAsync(string channelName, string username)
|
||||||
{
|
{
|
||||||
foreach (var conn in gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
if (conn.Nickname == username) continue;
|
if (conn.Nickname == username) continue;
|
||||||
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
|
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
|
||||||
@@ -43,9 +50,9 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
|||||||
var target = channelName ?? channel.Name;
|
var target = channelName ?? channel.Name;
|
||||||
if (channel.Topic is null) return;
|
if (channel.Topic is null) return;
|
||||||
|
|
||||||
foreach (var conn in gateway.GetConnectionsInChannel(target))
|
foreach (var conn in _gateway.GetConnectionsInChannel(target))
|
||||||
{
|
{
|
||||||
await conn.SendAsync($":{gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
|
await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +62,66 @@ public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendUserKickedAsync(string channelName, string username, string? reason)
|
||||||
|
{
|
||||||
|
var reasonText = reason is not null ? $" :{reason}" : "";
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} KICK #{channelName} {username}{reasonText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendUserBannedAsync(string username, string? reason)
|
||||||
|
{
|
||||||
|
var reasonText = reason ?? "You have been banned.";
|
||||||
|
foreach (var conn in _gateway.GetAllConnections())
|
||||||
|
{
|
||||||
|
if (conn.Nickname == username)
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {username} :You have been banned: {reasonText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
||||||
|
{
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :Message {messageId} was deleted in #{channelName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendChannelNukedAsync(string channelName)
|
||||||
|
{
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :All messages in #{channelName} have been cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendErrorAsync(string connectionId, string message)
|
public async Task SendErrorAsync(string connectionId, string message)
|
||||||
{
|
{
|
||||||
if (!connectionId.StartsWith("irc-")) return;
|
if (!connectionId.StartsWith("irc-")) return;
|
||||||
|
|
||||||
if (gateway.Connections.TryGetValue(connectionId, out var conn))
|
if (_gateway.Connections.TryGetValue(connectionId, out var conn))
|
||||||
{
|
{
|
||||||
await conn.SendAsync($":{gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ForceDisconnectUserAsync(List<string> connectionIds, string reason)
|
||||||
|
{
|
||||||
|
foreach (var connId in connectionIds)
|
||||||
|
{
|
||||||
|
if (!connId.StartsWith("irc-")) continue;
|
||||||
|
|
||||||
|
if (_gateway.Connections.TryGetValue(connId, out var conn))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await conn.SendAsync($"ERROR :Closing Link: {reason}");
|
||||||
|
await conn.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch { /* connection may already be closed */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Net.Security;
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -13,7 +14,7 @@ namespace EchoHub.Server.Irc;
|
|||||||
public sealed class IrcGatewayService : BackgroundService
|
public sealed class IrcGatewayService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IrcOptions _options;
|
private readonly IrcOptions _options;
|
||||||
private readonly IChatService _chatService;
|
private readonly IServiceProvider _services;
|
||||||
private readonly ILogger<IrcGatewayService> _logger;
|
private readonly ILogger<IrcGatewayService> _logger;
|
||||||
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
|
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
|
||||||
|
|
||||||
@@ -22,11 +23,11 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
public IrcGatewayService(
|
public IrcGatewayService(
|
||||||
IOptions<IrcOptions> options,
|
IOptions<IrcOptions> options,
|
||||||
IChatService chatService,
|
IServiceProvider services,
|
||||||
ILogger<IrcGatewayService> logger)
|
ILogger<IrcGatewayService> logger)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_chatService = chatService;
|
_services = services;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
|
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IEnumerable<IrcClientConnection> GetAllConnections()
|
||||||
|
{
|
||||||
|
return _connections.Values.Where(c => c.IsAuthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
@@ -109,10 +115,13 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
||||||
|
|
||||||
|
IChatService? chatService = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
chatService = _services.GetRequiredService<IChatService>();
|
||||||
var handler = new IrcCommandHandler(
|
var handler = new IrcCommandHandler(
|
||||||
connection, _options, _chatService, _logger);
|
connection, _options, chatService, _logger);
|
||||||
|
|
||||||
await handler.RunAsync(ct);
|
await handler.RunAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -126,10 +135,12 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
{
|
{
|
||||||
foreach (var ch in connection.JoinedChannels.ToList())
|
foreach (var ch in connection.JoinedChannels.ToList())
|
||||||
{
|
{
|
||||||
await _chatService.LeaveChannelAsync(
|
if (chatService is null) break;
|
||||||
|
await chatService.LeaveChannelAsync(
|
||||||
connection.ConnectionId, connection.Nickname!, ch);
|
connection.ConnectionId, connection.Nickname!, ch);
|
||||||
}
|
}
|
||||||
await _chatService.UserDisconnectedAsync(connection.ConnectionId);
|
if (chatService is not null)
|
||||||
|
await chatService.UserDisconnectedAsync(connection.ConnectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
_connections.TryRemove(connection.ConnectionId, out _);
|
_connections.TryRemove(connection.ConnectionId, out _);
|
||||||
@@ -141,17 +152,24 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Cancel ExecuteAsync first so listeners stop accepting
|
||||||
|
await base.StopAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Force-close any remaining client connections
|
||||||
foreach (var (_, conn) in _connections)
|
foreach (var (_, conn) in _connections)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await conn.SendAsync("ERROR :Server shutting down");
|
await conn.SendAsync("ERROR :Server shutting down")
|
||||||
await conn.DisposeAsync();
|
.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { await conn.DisposeAsync(); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_connections.Clear();
|
_connections.Clear();
|
||||||
|
|
||||||
await base.StopAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
public static class IrcMessageFormatter
|
public static partial class IrcMessageFormatter
|
||||||
{
|
{
|
||||||
private const int MaxIrcLineContentBytes = 400;
|
private const int MaxIrcLineContentBytes = 400;
|
||||||
|
|
||||||
@@ -22,6 +23,13 @@ public static class IrcMessageFormatter
|
|||||||
case MessageType.Text:
|
case MessageType.Text:
|
||||||
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||||
|
|
||||||
|
// Append embed previews if present
|
||||||
|
if (message.Embeds is { Count: > 0 })
|
||||||
|
{
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MessageType.Image:
|
case MessageType.Image:
|
||||||
@@ -33,7 +41,7 @@ public static class IrcMessageFormatter
|
|||||||
{
|
{
|
||||||
var trimmed = line.TrimEnd('\r');
|
var trimmed = line.TrimEnd('\r');
|
||||||
if (trimmed.Length > 0)
|
if (trimmed.Length > 0)
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -45,6 +53,62 @@ public static class IrcMessageFormatter
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> FormatEmbed(string prefix, string ircChannel, EmbedDto embed)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
|
||||||
|
var header = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
|
header.Add(embed.SiteName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
header.Add(embed.Title);
|
||||||
|
|
||||||
|
if (header.Count > 0)
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {string.Join(" \u2014 ", header)}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
var desc = embed.Description.Length > 200
|
||||||
|
? embed.Description[..197] + "..."
|
||||||
|
: embed.Description;
|
||||||
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {desc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients.
|
||||||
|
/// Also passes through content that already uses ANSI codes unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static string ColorTagsToAnsi(string text)
|
||||||
|
{
|
||||||
|
if (!text.Contains('{'))
|
||||||
|
return text;
|
||||||
|
|
||||||
|
return ColorTagRegex().Replace(text, match =>
|
||||||
|
{
|
||||||
|
if (match.Groups[1].Success) // {X} reset
|
||||||
|
return "\x1b[0m";
|
||||||
|
if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB}
|
||||||
|
{
|
||||||
|
var hex = match.Groups[3].Value;
|
||||||
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
|
var code = match.Groups[2].Value == "F" ? "38" : "48";
|
||||||
|
return $"\x1b[{code};2;{r};{g};{b}m";
|
||||||
|
}
|
||||||
|
return match.Value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
||||||
|
private static partial Regex ColorTagRegex();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ public static class IrcServiceExtensions
|
|||||||
if (builder.Configuration.GetValue<bool>("Irc:Enabled"))
|
if (builder.Configuration.GetValue<bool>("Irc:Enabled"))
|
||||||
{
|
{
|
||||||
builder.Services.AddSingleton<IrcGatewayService>();
|
builder.Services.AddSingleton<IrcGatewayService>();
|
||||||
builder.Services.AddSingleton<IChatBroadcaster>(sp =>
|
builder.Services.AddSingleton<IChatBroadcaster, IrcBroadcaster>();
|
||||||
new IrcBroadcaster(sp.GetRequiredService<IrcGatewayService>()));
|
|
||||||
builder.Services.AddHostedService(sp =>
|
builder.Services.AddHostedService(sp =>
|
||||||
sp.GetRequiredService<IrcGatewayService>());
|
sp.GetRequiredService<IrcGatewayService>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,25 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Auth;
|
namespace EchoHub.Server.Auth;
|
||||||
|
|
||||||
public class JwtTokenService(IConfiguration configuration)
|
public class JwtTokenService
|
||||||
{
|
{
|
||||||
private readonly string _secret = configuration["Jwt:Secret"]
|
private readonly string _secret;
|
||||||
?? throw new InvalidOperationException("Jwt:Secret is not configured.");
|
private readonly string _issuer;
|
||||||
private readonly string _issuer = configuration["Jwt:Issuer"]
|
private readonly string _audience;
|
||||||
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
|
||||||
private readonly string _audience = configuration["Jwt:Audience"]
|
|
||||||
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
|
||||||
|
|
||||||
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15);
|
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(15);
|
||||||
public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30);
|
public static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
public JwtTokenService(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_secret = configuration["Jwt:Secret"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Secret is not configured.");
|
||||||
|
_issuer = configuration["Jwt:Issuer"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
||||||
|
_audience = configuration["Jwt:Audience"]
|
||||||
|
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user)
|
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(User user)
|
||||||
{
|
{
|
||||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
||||||
@@ -30,6 +37,7 @@ public class JwtTokenService(IConfiguration configuration)
|
|||||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
new("username", user.Username),
|
new("username", user.Username),
|
||||||
new("display_name", user.DisplayName ?? user.Username),
|
new("display_name", user.DisplayName ?? user.Username),
|
||||||
|
new("role", user.Role.ToString()),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,17 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
[EnableRateLimiting("auth")]
|
[EnableRateLimiting("auth")]
|
||||||
public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly JwtTokenService _jwt;
|
||||||
|
|
||||||
|
public AuthController(EchoHubDbContext db, JwtTokenService jwt)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_jwt = jwt;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||||
{
|
{
|
||||||
@@ -31,31 +40,35 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
|
|
||||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||||
return Conflict(new ErrorResponse("Username is already taken."));
|
return Conflict(new ErrorResponse("Username is already taken."));
|
||||||
|
|
||||||
|
// First registered user on the server becomes the Owner
|
||||||
|
var isFirstUser = !await _db.Users.AnyAsync();
|
||||||
|
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Username = normalizedUsername,
|
Username = normalizedUsername,
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||||
DisplayName = request.DisplayName?.Trim(),
|
DisplayName = request.DisplayName?.Trim(),
|
||||||
|
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Users.Add(user);
|
_db.Users.Add(user);
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
|
||||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
}
|
}
|
||||||
@@ -67,25 +80,28 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
return BadRequest(new ErrorResponse("Username and password are required."));
|
return BadRequest(new ErrorResponse("Username and password are required."));
|
||||||
|
|
||||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
||||||
|
|
||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
if (user.IsBanned)
|
||||||
await db.SaveChangesAsync();
|
return Unauthorized(new ErrorResponse("Your account has been banned."));
|
||||||
|
|
||||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
|
||||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
}
|
}
|
||||||
@@ -97,7 +113,7 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
return BadRequest(new ErrorResponse("Refresh token is required."));
|
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||||
|
|
||||||
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||||
var storedToken = await db.RefreshTokens
|
var storedToken = await _db.RefreshTokens
|
||||||
.Include(r => r.User)
|
.Include(r => r.User)
|
||||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||||
|
|
||||||
@@ -111,17 +127,17 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
// Issue new token pair
|
// Issue new token pair
|
||||||
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
|
||||||
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
|
var newRefreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(newRefreshToken),
|
TokenHash = JwtTokenService.HashToken(newRefreshToken),
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
||||||
}
|
}
|
||||||
@@ -133,12 +149,12 @@ public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : Controll
|
|||||||
return BadRequest(new ErrorResponse("Refresh token is required."));
|
return BadRequest(new ErrorResponse("Refresh token is required."));
|
||||||
|
|
||||||
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
|
||||||
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
var storedToken = await _db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
|
||||||
|
|
||||||
if (storedToken is not null && storedToken.IsActive)
|
if (storedToken is not null && storedToken.IsActive)
|
||||||
{
|
{
|
||||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
|
|||||||
@@ -16,22 +16,45 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[Route("api/channels")]
|
[Route("api/channels")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class ChannelsController(
|
public class ChannelsController : ControllerBase
|
||||||
EchoHubDbContext db,
|
|
||||||
FileStorageService fileStorage,
|
|
||||||
ImageToAsciiService asciiService,
|
|
||||||
IHttpClientFactory httpClientFactory,
|
|
||||||
IChatService chatService) : ControllerBase
|
|
||||||
{
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly FileStorageService _fileStorage;
|
||||||
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IChatService _chatService;
|
||||||
|
|
||||||
|
public ChannelsController(
|
||||||
|
EchoHubDbContext db,
|
||||||
|
FileStorageService fileStorage,
|
||||||
|
ImageToAsciiService asciiService,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IChatService chatService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
|
_asciiService = asciiService;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_chatService = chatService;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
||||||
{
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
|
var userId = Guid.Parse(userIdClaim);
|
||||||
offset = Math.Max(0, offset);
|
offset = Math.Max(0, offset);
|
||||||
limit = Math.Clamp(limit, 1, 100);
|
limit = Math.Clamp(limit, 1, 100);
|
||||||
|
|
||||||
var total = await db.Channels.CountAsync();
|
// Public channels + private channels the user has joined
|
||||||
|
var query = _db.Channels.Where(c =>
|
||||||
|
c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
|
||||||
var channels = await db.Channels
|
var channels = await query
|
||||||
.OrderBy(c => c.Name)
|
.OrderBy(c => c.Name)
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(limit)
|
.Take(limit)
|
||||||
@@ -39,6 +62,7 @@ public class ChannelsController(
|
|||||||
c.Id,
|
c.Id,
|
||||||
c.Name,
|
c.Name,
|
||||||
c.Topic,
|
c.Topic,
|
||||||
|
c.IsPublic,
|
||||||
c.Messages.Count,
|
c.Messages.Count,
|
||||||
c.CreatedAt))
|
c.CreatedAt))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -57,7 +81,7 @@ public class ChannelsController(
|
|||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
|
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
|
||||||
|
|
||||||
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
if (await _db.Channels.AnyAsync(c => c.Name == channelName))
|
||||||
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
|
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
|
||||||
|
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
@@ -69,14 +93,24 @@ public class ChannelsController(
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = channelName,
|
Name = channelName,
|
||||||
Topic = request.Topic?.Trim(),
|
Topic = request.Topic?.Trim(),
|
||||||
|
IsPublic = request.IsPublic,
|
||||||
CreatedByUserId = Guid.Parse(userIdClaim),
|
CreatedByUserId = Guid.Parse(userIdClaim),
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Channels.Add(channel);
|
_db.Channels.Add(channel);
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
|
// Creator automatically becomes a member
|
||||||
await chatService.BroadcastChannelUpdatedAsync(dto);
|
_db.ChannelMemberships.Add(new ChannelMembership
|
||||||
|
{
|
||||||
|
UserId = Guid.Parse(userIdClaim),
|
||||||
|
ChannelId = channel.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
|
||||||
|
if (channel.IsPublic)
|
||||||
|
await _chatService.BroadcastChannelUpdatedAsync(dto);
|
||||||
|
|
||||||
return Created($"/api/channels/{channelName}", dto);
|
return Created($"/api/channels/{channelName}", dto);
|
||||||
}
|
}
|
||||||
@@ -89,7 +123,7 @@ public class ChannelsController(
|
|||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var channelName = channel.ToLowerInvariant().Trim();
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
@@ -101,11 +135,11 @@ public class ChannelsController(
|
|||||||
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
|
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
|
||||||
|
|
||||||
dbChannel.Topic = request.Topic?.Trim();
|
dbChannel.Topic = request.Topic?.Trim();
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
|
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
|
||||||
await chatService.BroadcastChannelUpdatedAsync(dto, channelName);
|
await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
|
||||||
|
|
||||||
return Ok(dto);
|
return Ok(dto);
|
||||||
}
|
}
|
||||||
@@ -122,23 +156,25 @@ public class ChannelsController(
|
|||||||
if (channelName == HubConstants.DefaultChannel)
|
if (channelName == HubConstants.DefaultChannel)
|
||||||
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
|
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
|
||||||
|
|
||||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
|
||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
var userId = Guid.Parse(userIdClaim);
|
||||||
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
|
var caller = await _db.Users.FindAsync(userId);
|
||||||
|
if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
|
||||||
|
return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
|
||||||
|
|
||||||
db.Channels.Remove(dbChannel);
|
_db.Channels.Remove(dbChannel);
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{channel}/upload")]
|
[HttpPost("{channel}/upload")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> Upload(string channel)
|
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
@@ -151,7 +187,7 @@ public class ChannelsController(
|
|||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||||
|
|
||||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
@@ -167,15 +203,16 @@ public class ChannelsController(
|
|||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||||
|
|
||||||
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
|
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||||
|
|
||||||
var messageType = isImage ? MessageType.Image : MessageType.File;
|
var messageType = isImage ? MessageType.Image : MessageType.File;
|
||||||
string content;
|
string content;
|
||||||
|
|
||||||
if (isImage)
|
if (isImage)
|
||||||
{
|
{
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||||
content = asciiService.ConvertToAscii(imageStream);
|
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -183,7 +220,7 @@ public class ChannelsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
@@ -198,8 +235,8 @@ public class ChannelsController(
|
|||||||
SenderUsername = usernameClaim,
|
SenderUsername = usernameClaim,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
@@ -212,14 +249,14 @@ public class ChannelsController(
|
|||||||
file.FileName,
|
file.FileName,
|
||||||
message.SentAt);
|
message.SentAt);
|
||||||
|
|
||||||
await chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
return Ok(messageDto);
|
return Ok(messageDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{channel}/send-url")]
|
[HttpPost("{channel}/send-url")]
|
||||||
[EnableRateLimiting("upload")]
|
[EnableRateLimiting("upload")]
|
||||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request)
|
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
||||||
{
|
{
|
||||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
var usernameClaim = User.FindFirstValue("username");
|
var usernameClaim = User.FindFirstValue("username");
|
||||||
@@ -232,7 +269,7 @@ public class ChannelsController(
|
|||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||||
|
|
||||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
@@ -248,7 +285,7 @@ public class ChannelsController(
|
|||||||
string fileName;
|
string fileName;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var client = httpClientFactory.CreateClient("ImageDownload");
|
using var client = _httpClientFactory.CreateClient("ImageDownload");
|
||||||
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
@@ -291,16 +328,17 @@ public class ChannelsController(
|
|||||||
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||||
|
|
||||||
// Save file and convert to ASCII
|
// Save file and convert to ASCII
|
||||||
var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName);
|
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
|
||||||
|
|
||||||
string content;
|
string content;
|
||||||
|
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||||
using (var imageStream = System.IO.File.OpenRead(filePath))
|
using (var imageStream = System.IO.File.OpenRead(filePath))
|
||||||
{
|
{
|
||||||
content = asciiService.ConvertToAscii(imageStream);
|
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
@@ -315,8 +353,8 @@ public class ChannelsController(
|
|||||||
SenderUsername = usernameClaim,
|
SenderUsername = usernameClaim,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
@@ -329,7 +367,7 @@ public class ChannelsController(
|
|||||||
fileName,
|
fileName,
|
||||||
message.SentAt);
|
message.SentAt);
|
||||||
|
|
||||||
await chatService.BroadcastMessageAsync(channelName, messageDto);
|
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
return Ok(messageDto);
|
return Ok(messageDto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[Route("api/files")]
|
[Route("api/files")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class FilesController(FileStorageService fileStorage) : ControllerBase
|
public class FilesController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly FileStorageService _fileStorage;
|
||||||
|
|
||||||
|
public FilesController(FileStorageService fileStorage)
|
||||||
|
{
|
||||||
|
_fileStorage = fileStorage;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{fileId}")]
|
[HttpGet("{fileId}")]
|
||||||
public IActionResult GetFile(string fileId)
|
public IActionResult GetFile(string fileId)
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(fileId, out _))
|
if (!Guid.TryParse(fileId, out _))
|
||||||
return BadRequest(new ErrorResponse("Invalid file identifier."));
|
return BadRequest(new ErrorResponse("Invalid file identifier."));
|
||||||
|
|
||||||
var filePath = fileStorage.GetFilePath(fileId);
|
var filePath = _fileStorage.GetFilePath(fileId);
|
||||||
|
|
||||||
if (filePath is null)
|
if (filePath is null)
|
||||||
return NotFound(new ErrorResponse("File not found."));
|
return NotFound(new ErrorResponse("File not found."));
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/moderation")]
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("general")]
|
||||||
|
public class ModerationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly IChatService _chatService;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
|
||||||
|
public ModerationController(
|
||||||
|
EchoHubDbContext db,
|
||||||
|
IChatService chatService,
|
||||||
|
PresenceTracker presenceTracker,
|
||||||
|
IEnumerable<IChatBroadcaster> broadcasters)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_chatService = chatService;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_broadcasters = broadcasters;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("role")]
|
||||||
|
public async Task<IActionResult> AssignRole([FromBody] AssignRoleRequest request)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
if (request.Role == ServerRole.Owner)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot assign the Owner role."));
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{request.Username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role == ServerRole.Owner)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot change the server owner's role."));
|
||||||
|
|
||||||
|
if (request.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
|
||||||
|
|
||||||
|
target.Role = request.Role;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("kick/{username}")]
|
||||||
|
public async Task<IActionResult> KickUser(string username, [FromBody] KickRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role."));
|
||||||
|
|
||||||
|
// Broadcast kick to all channels the user is in, then clean up presence
|
||||||
|
var channels = _presenceTracker.GetChannelsForUser(target.Username);
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from presence tracker and force disconnect all connections
|
||||||
|
var reason = request?.Reason ?? "You have been kicked from the server.";
|
||||||
|
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been kicked." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("ban/{username}")]
|
||||||
|
public async Task<IActionResult> BanUser(string username, [FromBody] BanRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot ban a user with equal or higher role."));
|
||||||
|
|
||||||
|
target.IsBanned = true;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Broadcast ban notification, then force disconnect
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason));
|
||||||
|
|
||||||
|
var reason = request?.Reason ?? "You have been banned from this server.";
|
||||||
|
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been banned." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("unban/{username}")]
|
||||||
|
public async Task<IActionResult> UnbanUser(string username)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
target.IsBanned = false;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been unbanned." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("mute/{username}")]
|
||||||
|
public async Task<IActionResult> MuteUser(string username, [FromBody] MuteRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot mute a user with equal or higher role."));
|
||||||
|
|
||||||
|
target.IsMuted = true;
|
||||||
|
target.MutedUntil = request?.DurationMinutes is > 0
|
||||||
|
? DateTimeOffset.UtcNow.AddMinutes(request.DurationMinutes.Value)
|
||||||
|
: null;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
|
||||||
|
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("unmute/{username}")]
|
||||||
|
public async Task<IActionResult> UnmuteUser(string username)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
target.IsMuted = false;
|
||||||
|
target.MutedUntil = null;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been unmuted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("messages/{messageId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteMessage(Guid messageId)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var message = await _db.Messages
|
||||||
|
.Include(m => m.Channel)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == messageId);
|
||||||
|
|
||||||
|
if (message is null)
|
||||||
|
return NotFound(new ErrorResponse("Message not found."));
|
||||||
|
|
||||||
|
var channelName = message.Channel!.Name;
|
||||||
|
_db.Messages.Remove(message);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
|
||||||
|
|
||||||
|
return Ok(new { Message = "Message deleted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("channels/{channel}/nuke")]
|
||||||
|
public async Task<IActionResult> NukeChannel(string channel)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
|
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (dbChannel is null)
|
||||||
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
|
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
|
||||||
|
_db.Messages.RemoveRange(messages);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
|
||||||
|
|
||||||
|
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
|
||||||
|
|
||||||
|
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||||
|
if (caller is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("User not found.")));
|
||||||
|
|
||||||
|
if (caller.Role < minimumRole)
|
||||||
|
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
|
||||||
|
|
||||||
|
return (caller, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove user from presence tracking, broadcast their departure from all channels,
|
||||||
|
/// send a ForceDisconnect signal, and update their DB status.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ForceDisconnectAndCleanupAsync(string username, string reason)
|
||||||
|
{
|
||||||
|
var (connectionIds, channels) = _presenceTracker.ForceRemoveUser(username);
|
||||||
|
|
||||||
|
// Notify remaining users that this person left each channel
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channel, username));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal the user's clients to disconnect
|
||||||
|
if (connectionIds.Count > 0)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.ForceDisconnectUserAsync(connectionIds, reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark user offline in DB
|
||||||
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
user.Status = UserStatus.Invisible;
|
||||||
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
||||||
|
{
|
||||||
|
foreach (var broadcaster in _broadcasters)
|
||||||
|
{
|
||||||
|
try { await action(broadcaster); }
|
||||||
|
catch { /* logged by broadcaster */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,26 @@ namespace EchoHub.Server.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/server")]
|
[Route("api/server")]
|
||||||
public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase
|
public class ServerController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
|
||||||
|
public ServerController(EchoHubDbContext db, IConfiguration config)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("info")]
|
[HttpGet("info")]
|
||||||
public async Task<IActionResult> GetInfo()
|
public async Task<IActionResult> GetInfo()
|
||||||
{
|
{
|
||||||
var userCount = await db.Users.CountAsync();
|
var userCount = await _db.Users.CountAsync();
|
||||||
var channelCount = await db.Channels.CountAsync();
|
var channelCount = await _db.Channels.CountAsync();
|
||||||
|
|
||||||
var status = new ServerStatusDto(
|
var status = new ServerStatusDto(
|
||||||
config["Server:Name"] ?? "EchoHub Server",
|
_config["Server:Name"] ?? "EchoHub Server",
|
||||||
config["Server:Description"],
|
_config["Server:Description"],
|
||||||
userCount,
|
userCount,
|
||||||
channelCount);
|
channelCount);
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
|
||||||
|
public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_asciiService = asciiService;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
public async Task<IActionResult> GetProfile(string username)
|
public async Task<IActionResult> GetProfile(string username)
|
||||||
{
|
{
|
||||||
var normalizedUsername = username.ToLowerInvariant().Trim();
|
var normalizedUsername = username.ToLowerInvariant().Trim();
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
@@ -36,7 +45,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var userId = Guid.Parse(userIdClaim);
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await _db.Users.FindAsync(userId);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
@@ -63,7 +72,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
user.NicknameColor = color.Length > 0 ? color : null;
|
user.NicknameColor = color.Length > 0 ? color : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(ToProfileDto(user));
|
return Ok(ToProfileDto(user));
|
||||||
}
|
}
|
||||||
@@ -77,7 +86,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var userId = Guid.Parse(userIdClaim);
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await _db.Users.FindAsync(userId);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
@@ -95,10 +104,10 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
if (!FileValidationHelper.IsValidImage(stream))
|
if (!FileValidationHelper.IsValidImage(stream))
|
||||||
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
|
||||||
|
|
||||||
var asciiArt = asciiService.ConvertToAscii(stream);
|
var asciiArt = _asciiService.ConvertToAscii(stream);
|
||||||
|
|
||||||
user.AvatarAscii = asciiArt;
|
user.AvatarAscii = asciiArt;
|
||||||
await db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new AvatarUploadResponse(asciiArt));
|
return Ok(new AvatarUploadResponse(asciiArt));
|
||||||
}
|
}
|
||||||
@@ -112,6 +121,7 @@ public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiServi
|
|||||||
user.AvatarAscii,
|
user.AvatarAscii,
|
||||||
user.Status,
|
user.Status,
|
||||||
user.StatusMessage,
|
user.StatusMessage,
|
||||||
|
user.Role,
|
||||||
user.CreatedAt,
|
user.CreatedAt,
|
||||||
user.LastSeenAt);
|
user.LastSeenAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Data;
|
namespace EchoHub.Server.Data;
|
||||||
|
|
||||||
public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbContext(options)
|
public class EchoHubDbContext : DbContext
|
||||||
{
|
{
|
||||||
|
public EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : base(options) { }
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<Channel> Channels => Set<Channel>();
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
public DbSet<Message> Messages => Set<Message>();
|
public DbSet<Message> Messages => Set<Message>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -33,6 +35,7 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
|||||||
entity.Property(u => u.NicknameColor).HasMaxLength(7);
|
entity.Property(u => u.NicknameColor).HasMaxLength(7);
|
||||||
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
|
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
|
||||||
entity.Property(u => u.StatusMessage).HasMaxLength(100);
|
entity.Property(u => u.StatusMessage).HasMaxLength(100);
|
||||||
|
entity.Property(u => u.Role).HasConversion<int>();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Channel>(entity =>
|
modelBuilder.Entity<Channel>(entity =>
|
||||||
@@ -56,6 +59,24 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
|||||||
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
|
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
|
||||||
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
||||||
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
||||||
|
entity.Property(m => m.EmbedJson).HasMaxLength(8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ChannelMembership>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(cm => new { cm.UserId, cm.ChannelId });
|
||||||
|
entity.HasIndex(cm => cm.UserId);
|
||||||
|
entity.HasIndex(cm => cm.ChannelId);
|
||||||
|
|
||||||
|
entity.HasOne<Channel>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(cm => cm.ChannelId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne<User>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(cm => cm.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RefreshToken>(entity =>
|
modelBuilder.Entity<RefreshToken>(entity =>
|
||||||
|
|||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260219162414_AddModerationRoles")]
|
||||||
|
partial class AddModerationRoles
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddModerationRoles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsBanned",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsMuted",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "MutedUntil",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Role",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsBanned",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsMuted",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MutedUntil",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Role",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260219172834_AddChannelIsPublic")]
|
||||||
|
partial class AddChannelIsPublic
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelIsPublic : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsPublic",
|
||||||
|
table: "Channels",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsPublic",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260219181720_AddChannelMembership")]
|
||||||
|
partial class AddChannelMembership
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelMembership : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ChannelMemberships",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
ChannelId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
JoinedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ChannelMemberships", x => new { x.UserId, x.ChannelId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ChannelMemberships_Channels_ChannelId",
|
||||||
|
column: x => x.ChannelId,
|
||||||
|
principalTable: "Channels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ChannelMemberships_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ChannelMemberships_ChannelId",
|
||||||
|
table: "ChannelMemberships",
|
||||||
|
column: "ChannelId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ChannelMemberships_UserId",
|
||||||
|
table: "ChannelMemberships",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ChannelMemberships");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260219201704_AddMessageEmbed")]
|
||||||
|
partial class AddMessageEmbed
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(8000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMessageEmbed : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "EmbedJson",
|
||||||
|
table: "Messages",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 8000,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EmbedJson",
|
||||||
|
table: "Messages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.Property<Guid>("CreatedByUserId")
|
b.Property<Guid>("CreatedByUserId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -46,6 +49,26 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("Channels");
|
b.ToTable("Channels");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -68,6 +91,10 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(8000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid>("SenderUserId")
|
b.Property<Guid>("SenderUserId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -144,9 +171,18 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<long>("LastSeenAt")
|
b.Property<long>("LastSeenAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("NicknameColor")
|
b.Property<string>("NicknameColor")
|
||||||
.HasMaxLength(7)
|
.HasMaxLength(7)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -155,6 +191,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -175,6 +214,21 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("Users");
|
b.ToTable("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
|||||||
@@ -9,8 +9,17 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
namespace EchoHub.Server.Hubs;
|
namespace EchoHub.Server.Hubs;
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IEchoHubClient>
|
public class ChatHub : Hub<IEchoHubClient>
|
||||||
{
|
{
|
||||||
|
private readonly IChatService _chatService;
|
||||||
|
private readonly ILogger<ChatHub> _logger;
|
||||||
|
|
||||||
|
public ChatHub(IChatService chatService, ILogger<ChatHub> logger)
|
||||||
|
{
|
||||||
|
_chatService = chatService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
private Guid CurrentUserId =>
|
private Guid CurrentUserId =>
|
||||||
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
|
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
?? throw new HubException("User ID claim not found."));
|
?? throw new HubException("User ID claim not found."));
|
||||||
@@ -23,12 +32,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
await _chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
||||||
await base.OnConnectedAsync();
|
await base.OnConnectedAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId);
|
_logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,12 +46,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await chatService.UserDisconnectedAsync(Context.ConnectionId);
|
await _chatService.UserDisconnectedAsync(Context.ConnectionId);
|
||||||
await base.OnDisconnectedAsync(exception);
|
await base.OnDisconnectedAsync(exception);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId);
|
_logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +60,7 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (history, error) = await chatService.JoinChannelAsync(
|
var (history, error) = await _chatService.JoinChannelAsync(
|
||||||
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
|
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
|
||||||
|
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
@@ -65,7 +74,7 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
|
_logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
|
await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -76,12 +85,12 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
await chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
|
await _chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
|
||||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername);
|
_logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to leave channel: {ex.Message}");
|
await Clients.Caller.Error($"Failed to leave channel: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,13 +99,13 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var error = await chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
|
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
await Clients.Caller.Error(error);
|
await Clients.Caller.Error(error);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername);
|
_logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to send message: {ex.Message}");
|
await Clients.Caller.Error($"Failed to send message: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,11 +114,11 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await chatService.GetChannelHistoryAsync(channelName, count);
|
return await _chatService.GetChannelHistoryAsync(channelName, count);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error fetching history for '{Channel}'", channelName);
|
_logger.LogError(ex, "Error fetching history for '{Channel}'", channelName);
|
||||||
await Clients.Caller.Error($"Failed to load history: {ex.Message}");
|
await Clients.Caller.Error($"Failed to load history: {ex.Message}");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -119,13 +128,13 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var error = await chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
|
var error = await _chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
await Clients.Caller.Error(error);
|
await Clients.Caller.Error(error);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error updating status for {User}", CurrentUsername);
|
_logger.LogError(ex, "Error updating status for {User}", CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to update status: {ex.Message}");
|
await Clients.Caller.Error($"Failed to update status: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,11 +143,11 @@ public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IE
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await chatService.GetOnlineUsersAsync(channelName);
|
return await _chatService.GetOnlineUsersAsync(channelName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error listing users in '{Channel}'", channelName);
|
_logger.LogError(ex, "Error listing users in '{Channel}'", channelName);
|
||||||
await Clients.Caller.Error($"Failed to list users: {ex.Message}");
|
await Clients.Caller.Error($"Failed to list users: {ex.Message}");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ while (true)
|
|||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── Host options ────────────────────────────────────────────────────
|
||||||
|
builder.Services.Configure<HostOptions>(options =>
|
||||||
|
options.ShutdownTimeout = TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
// ── Serilog ──────────────────────────────────────────────────────────
|
// ── Serilog ──────────────────────────────────────────────────────────
|
||||||
builder.Host.UseSerilog((context, config) =>
|
builder.Host.UseSerilog((context, config) =>
|
||||||
config.ReadFrom.Configuration(context.Configuration));
|
config.ReadFrom.Configuration(context.Configuration));
|
||||||
@@ -102,6 +106,7 @@ while (true)
|
|||||||
builder.Services.AddSingleton<PresenceTracker>();
|
builder.Services.AddSingleton<PresenceTracker>();
|
||||||
builder.Services.AddSingleton<ImageToAsciiService>();
|
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
|
|
||||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||||
@@ -117,6 +122,13 @@ while (true)
|
|||||||
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
|
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient("OgFetch", client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(5);
|
||||||
|
client.MaxResponseContentBufferSize = 256 * 1024; // 256 KB
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub/1.0 (Link Preview Bot)");
|
||||||
|
});
|
||||||
|
|
||||||
// ── Rate Limiting ────────────────────────────────────────────────────
|
// ── Rate Limiting ────────────────────────────────────────────────────
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
@@ -9,17 +10,33 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
public class ChatService(
|
public class ChatService : IChatService
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
PresenceTracker presenceTracker,
|
|
||||||
IEnumerable<IChatBroadcaster> broadcasters,
|
|
||||||
ILogger<ChatService> logger) : IChatService
|
|
||||||
{
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
private readonly LinkEmbedService _embedService;
|
||||||
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
|
public ChatService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
PresenceTracker presenceTracker,
|
||||||
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
|
LinkEmbedService embedService,
|
||||||
|
ILogger<ChatService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_broadcasters = broadcasters;
|
||||||
|
_embedService = embedService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
||||||
{
|
{
|
||||||
presenceTracker.UserConnected(connectionId, userId, username);
|
_presenceTracker.UserConnected(connectionId, userId, username);
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await db.Users.FindAsync(userId);
|
||||||
@@ -30,21 +47,21 @@ public class ChatService(
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
_logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
||||||
{
|
{
|
||||||
var preDisconnectUsername = presenceTracker.GetUsernameForConnection(connectionId);
|
var preDisconnectUsername = _presenceTracker.GetUsernameForConnection(connectionId);
|
||||||
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
||||||
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
? _presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
var username = presenceTracker.UserDisconnected(connectionId);
|
var username = _presenceTracker.UserDisconnected(connectionId);
|
||||||
|
|
||||||
if (username is not null && !presenceTracker.IsOnline(username))
|
if (username is not null && !_presenceTracker.IsOnline(username))
|
||||||
{
|
{
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
@@ -59,13 +76,14 @@ public class ChatService(
|
|||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.NicknameColor,
|
user.NicknameColor,
|
||||||
UserStatus.Invisible,
|
UserStatus.Invisible,
|
||||||
user.StatusMessage);
|
user.StatusMessage,
|
||||||
|
user.Role);
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
_logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,19 +95,32 @@ public class ChatService(
|
|||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
|
||||||
|
|
||||||
var isNewJoin = presenceTracker.JoinChannel(username, channelName);
|
// Persist membership so the channel shows in the user's channel list
|
||||||
|
var hasMembership = await db.ChannelMemberships
|
||||||
|
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
|
||||||
|
if (!hasMembership)
|
||||||
|
{
|
||||||
|
db.ChannelMemberships.Add(new ChannelMembership
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
ChannelId = channel.Id,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
||||||
|
|
||||||
if (isNewJoin)
|
if (isNewJoin)
|
||||||
{
|
{
|
||||||
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
|
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
|
||||||
logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount);
|
var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount);
|
||||||
@@ -99,9 +130,9 @@ public class ChatService(
|
|||||||
public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
presenceTracker.LeaveChannel(username, channelName);
|
_presenceTracker.LeaveChannel(username, channelName);
|
||||||
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
||||||
logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
|
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
|
||||||
@@ -117,7 +148,10 @@ public class ChatService(
|
|||||||
if (content.Length > HubConstants.MaxMessageLength)
|
if (content.Length > HubConstants.MaxMessageLength)
|
||||||
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
|
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
// Sanitize: collapse excessive newlines
|
||||||
|
content = SanitizeNewlines(content);
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
@@ -126,6 +160,32 @@ public class ChatService(
|
|||||||
|
|
||||||
var sender = await db.Users.FindAsync(userId);
|
var sender = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
|
// Check mute status
|
||||||
|
if (sender is not null && sender.IsMuted)
|
||||||
|
{
|
||||||
|
if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
sender.IsMuted = false;
|
||||||
|
sender.MutedUntil = null;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return "You are muted and cannot send messages.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to fetch link embeds for URLs in the message
|
||||||
|
List<EmbedDto>? embeds = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
embeds = await _embedService.TryGetEmbedsAsync(content);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
|
||||||
|
}
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -135,6 +195,7 @@ public class ChatService(
|
|||||||
ChannelId = channel.Id,
|
ChannelId = channel.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = username,
|
SenderUsername = username,
|
||||||
|
EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
db.Messages.Add(message);
|
||||||
@@ -149,11 +210,12 @@ public class ChatService(
|
|||||||
MessageType.Text,
|
MessageType.Text,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
message.SentAt);
|
message.SentAt,
|
||||||
|
embeds);
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
||||||
|
|
||||||
logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
_logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +224,7 @@ public class ChatService(
|
|||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await GetChannelHistoryInternalAsync(db, channelName, count);
|
return await GetChannelHistoryInternalAsync(db, channelName, count);
|
||||||
@@ -173,7 +235,7 @@ public class ChatService(
|
|||||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||||
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FindAsync(userId);
|
var user = await db.Users.FindAsync(userId);
|
||||||
@@ -190,9 +252,10 @@ public class ChatService(
|
|||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.NicknameColor,
|
user.NicknameColor,
|
||||||
status,
|
status,
|
||||||
statusMessage);
|
statusMessage,
|
||||||
|
user.Role);
|
||||||
|
|
||||||
var channels = presenceTracker.GetChannelsForUser(username);
|
var channels = _presenceTracker.GetChannelsForUser(username);
|
||||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -201,9 +264,9 @@ public class ChatService(
|
|||||||
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
|
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
|
var onlineUsernames = _presenceTracker.GetOnlineUsersInChannel(channelName);
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await db.Users
|
return await db.Users
|
||||||
@@ -213,7 +276,8 @@ public class ChatService(
|
|||||||
u.DisplayName,
|
u.DisplayName,
|
||||||
u.NicknameColor,
|
u.NicknameColor,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.StatusMessage))
|
u.StatusMessage,
|
||||||
|
u.Role))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +289,7 @@ public class ChatService(
|
|||||||
|
|
||||||
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
||||||
{
|
{
|
||||||
foreach (var broadcaster in broadcasters)
|
foreach (var broadcaster in _broadcasters)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -233,7 +297,7 @@ public class ChatService(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name);
|
_logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,7 +306,7 @@ public class ChatService(
|
|||||||
{
|
{
|
||||||
username = username.ToLowerInvariant();
|
username = username.ToLowerInvariant();
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
@@ -251,14 +315,14 @@ public class ChatService(
|
|||||||
return new UserProfileDto(
|
return new UserProfileDto(
|
||||||
user.Id, user.Username, user.DisplayName, user.Bio,
|
user.Id, user.Username, user.DisplayName, user.Bio,
|
||||||
user.NicknameColor, user.AvatarAscii, user.Status,
|
user.NicknameColor, user.AvatarAscii, user.Status,
|
||||||
user.StatusMessage, user.CreatedAt, user.LastSeenAt);
|
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
|
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
@@ -269,7 +333,7 @@ public class ChatService(
|
|||||||
|
|
||||||
public async Task<List<ChannelListItem>> GetChannelListAsync()
|
public async Task<List<ChannelListItem>> GetChannelListAsync()
|
||||||
{
|
{
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
|
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
|
||||||
@@ -277,17 +341,17 @@ public class ChatService(
|
|||||||
return channels.Select(c => new ChannelListItem(
|
return channels.Select(c => new ChannelListItem(
|
||||||
c.Name,
|
c.Name,
|
||||||
c.Topic,
|
c.Topic,
|
||||||
presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
|
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<string>> GetChannelsForUserAsync(string username)
|
public Task<List<string>> GetChannelsForUserAsync(string username)
|
||||||
=> Task.FromResult(presenceTracker.GetChannelsForUser(username));
|
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
|
||||||
|
|
||||||
public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
|
public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
|
||||||
{
|
{
|
||||||
username = username.ToLowerInvariant();
|
username = username.ToLowerInvariant();
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
@@ -299,32 +363,79 @@ public class ChatService(
|
|||||||
return (user.Id, user.Username);
|
return (user.Id, user.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
|
||||||
|
/// </summary>
|
||||||
|
private static string SanitizeNewlines(string content)
|
||||||
|
{
|
||||||
|
// Normalize \r\n → \n
|
||||||
|
content = content.Replace("\r\n", "\n").Replace('\r', '\n');
|
||||||
|
|
||||||
|
// Collapse consecutive blank/whitespace-only lines into max 1 blank line
|
||||||
|
var lines = content.Split('\n');
|
||||||
|
var result = new List<string>(lines.Length);
|
||||||
|
int consecutiveBlanks = 0;
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
consecutiveBlanks++;
|
||||||
|
if (consecutiveBlanks <= HubConstants.MaxConsecutiveNewlines)
|
||||||
|
result.Add(line);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
consecutiveBlanks = 0;
|
||||||
|
result.Add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap total lines
|
||||||
|
if (result.Count > HubConstants.MaxMessageNewlines)
|
||||||
|
result = result.Take(HubConstants.MaxMessageNewlines).ToList();
|
||||||
|
|
||||||
|
return string.Join('\n', result);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
||||||
{
|
{
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var messages = await db.Messages
|
var raw = await db.Messages
|
||||||
.Where(m => m.ChannelId == channel.Id)
|
.Where(m => m.ChannelId == channel.Id)
|
||||||
.OrderByDescending(m => m.SentAt)
|
.OrderByDescending(m => m.SentAt)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.Join(db.Users,
|
.Join(db.Users,
|
||||||
m => m.SenderUserId,
|
m => m.SenderUserId,
|
||||||
u => u.Id,
|
u => u.Id,
|
||||||
(m, u) => new MessageDto(
|
(m, u) => new { m, u.NicknameColor })
|
||||||
m.Id,
|
|
||||||
m.Content,
|
|
||||||
m.SenderUsername,
|
|
||||||
u.NicknameColor,
|
|
||||||
channelName,
|
|
||||||
m.Type,
|
|
||||||
m.AttachmentUrl,
|
|
||||||
m.AttachmentFileName,
|
|
||||||
m.SentAt))
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
messages.Reverse();
|
raw.Reverse();
|
||||||
return messages;
|
|
||||||
|
return raw.Select(x =>
|
||||||
|
{
|
||||||
|
List<EmbedDto>? embeds = null;
|
||||||
|
if (x.m.EmbedJson is not null)
|
||||||
|
{
|
||||||
|
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); }
|
||||||
|
catch { /* ignore malformed JSON */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MessageDto(
|
||||||
|
x.m.Id,
|
||||||
|
x.m.Content,
|
||||||
|
x.m.SenderUsername,
|
||||||
|
x.NicknameColor,
|
||||||
|
channelName,
|
||||||
|
x.m.Type,
|
||||||
|
x.m.AttachmentUrl,
|
||||||
|
x.m.AttachmentFileName,
|
||||||
|
x.m.SentAt,
|
||||||
|
embeds);
|
||||||
|
}).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,47 +8,81 @@ namespace EchoHub.Server.Services;
|
|||||||
|
|
||||||
public class ImageToAsciiService
|
public class ImageToAsciiService
|
||||||
{
|
{
|
||||||
private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray();
|
/// <summary>
|
||||||
|
/// Returns (width, height) dimensions for the given size code.
|
||||||
|
/// s = small (40x40), m = medium/default (80x80), l = large (120x120).
|
||||||
|
/// </summary>
|
||||||
|
public static (int Width, int Height) GetDimensions(string? size) => size?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"s" => (40, 40),
|
||||||
|
"l" => (120, 120),
|
||||||
|
_ => (HubConstants.AsciiArtWidth, HubConstants.AsciiArtHeightHalfBlock),
|
||||||
|
};
|
||||||
|
|
||||||
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight)
|
/// <summary>
|
||||||
|
/// Converts an image to ASCII art using half-block characters (▀▄█) with
|
||||||
|
/// printable color tags for 2x vertical resolution.
|
||||||
|
/// Each character cell represents two vertical pixels.
|
||||||
|
/// Format: {F:RRGGBB} foreground, {B:RRGGBB} background, {X} reset.
|
||||||
|
/// Uses only printable ASCII — no terminal escape bytes.
|
||||||
|
/// </summary>
|
||||||
|
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock)
|
||||||
{
|
{
|
||||||
using var image = Image.Load<Rgba32>(imageStream);
|
using var image = Image.Load<Rgba32>(imageStream);
|
||||||
|
|
||||||
|
// Ensure height is even for pair processing
|
||||||
|
if (height % 2 != 0) height++;
|
||||||
|
|
||||||
image.Mutate(x => x.Resize(width, height));
|
image.Mutate(x => x.Resize(width, height));
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
byte lastR = 0, lastG = 0, lastB = 0;
|
for (int y = 0; y < image.Height; y += 2)
|
||||||
bool hasLastColor = false;
|
|
||||||
|
|
||||||
for (int y = 0; y < image.Height; y++)
|
|
||||||
{
|
{
|
||||||
|
byte lastFgR = 0, lastFgG = 0, lastFgB = 0;
|
||||||
|
byte lastBgR = 0, lastBgG = 0, lastBgB = 0;
|
||||||
|
bool hasLastColor = false;
|
||||||
|
|
||||||
for (int x = 0; x < image.Width; x++)
|
for (int x = 0; x < image.Width; x++)
|
||||||
{
|
{
|
||||||
var pixel = image[x, y];
|
var topPixel = image[x, y];
|
||||||
var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B;
|
var bottomPixel = (y + 1 < image.Height) ? image[x, y + 1] : topPixel;
|
||||||
|
|
||||||
// Map brightness (0-255) to ASCII char index
|
byte fgR, fgG, fgB, bgR, bgG, bgB;
|
||||||
var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1));
|
char blockChar;
|
||||||
|
|
||||||
// Emit ANSI 24-bit color only when it changes
|
if (topPixel.R == bottomPixel.R && topPixel.G == bottomPixel.G && topPixel.B == bottomPixel.B)
|
||||||
if (!hasLastColor || pixel.R != lastR || pixel.G != lastG || pixel.B != lastB)
|
|
||||||
{
|
{
|
||||||
sb.Append($"\x1b[38;2;{pixel.R};{pixel.G};{pixel.B}m");
|
fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
|
||||||
lastR = pixel.R;
|
bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B;
|
||||||
lastG = pixel.G;
|
blockChar = '\u2588'; // █
|
||||||
lastB = pixel.B;
|
}
|
||||||
hasLastColor = true;
|
else
|
||||||
|
{
|
||||||
|
fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
|
||||||
|
bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B;
|
||||||
|
blockChar = '\u2580'; // ▀
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.Append(AsciiChars[index]);
|
bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB;
|
||||||
|
bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB;
|
||||||
|
|
||||||
|
if (fgChanged)
|
||||||
|
sb.Append($"{{F:{fgR:X2}{fgG:X2}{fgB:X2}}}");
|
||||||
|
if (bgChanged)
|
||||||
|
sb.Append($"{{B:{bgR:X2}{bgG:X2}{bgB:X2}}}");
|
||||||
|
|
||||||
|
sb.Append(blockChar);
|
||||||
|
|
||||||
|
lastFgR = fgR; lastFgG = fgG; lastFgB = fgB;
|
||||||
|
lastBgR = bgR; lastBgG = bgG; lastBgB = bgB;
|
||||||
|
hasLastColor = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset color at end of line
|
sb.Append("{X}");
|
||||||
sb.Append("\x1b[0m");
|
|
||||||
hasLastColor = false;
|
hasLastColor = false;
|
||||||
|
|
||||||
if (y < image.Height - 1)
|
if (y + 2 < image.Height)
|
||||||
{
|
{
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public partial class LinkEmbedService
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ILogger<LinkEmbedService> _logger;
|
||||||
|
|
||||||
|
public LinkEmbedService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<LinkEmbedService> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect all URLs in message content and attempt to fetch OG embed data for each.
|
||||||
|
/// Returns null if no URLs found or all fetches fail.
|
||||||
|
/// Never throws — all errors are caught internally.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<EmbedDto>?> TryGetEmbedsAsync(string content)
|
||||||
|
{
|
||||||
|
var urls = ExtractUrls(content);
|
||||||
|
if (urls.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var embeds = new List<EmbedDto>();
|
||||||
|
|
||||||
|
foreach (var url in urls)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var embed = await FetchEmbedForUrlAsync(url);
|
||||||
|
if (embed is not null)
|
||||||
|
embeds.Add(embed);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to fetch embed for {Url}", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return embeds.Count > 0 ? embeds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmbedDto?> FetchEmbedForUrlAsync(string url)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (uri.Scheme is not ("http" or "https"))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (IsPrivateHost(uri))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource(
|
||||||
|
TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds));
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient("OgFetch");
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
using var response = await client.SendAsync(request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||||
|
if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token);
|
||||||
|
if (string.IsNullOrWhiteSpace(html))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var ogTags = ParseOgTags(html);
|
||||||
|
|
||||||
|
// Try og:title, fallback to <title> tag
|
||||||
|
var title = ogTags.GetValueOrDefault("title");
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
{
|
||||||
|
var titleMatch = TitleTagRegex().Match(html);
|
||||||
|
if (titleMatch.Success)
|
||||||
|
title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no title at all, nothing useful to show
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var siteName = ogTags.GetValueOrDefault("site_name");
|
||||||
|
var description = ogTags.GetValueOrDefault("description");
|
||||||
|
|
||||||
|
// Truncate very long descriptions but keep a generous limit
|
||||||
|
if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength)
|
||||||
|
description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "...";
|
||||||
|
|
||||||
|
// HTML decode text fields
|
||||||
|
title = WebUtility.HtmlDecode(title);
|
||||||
|
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
||||||
|
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
||||||
|
|
||||||
|
return new EmbedDto(siteName, title, description, null, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ExtractUrls(string content)
|
||||||
|
{
|
||||||
|
var urls = new List<string>();
|
||||||
|
|
||||||
|
foreach (Match match in UrlRegex().Matches(content))
|
||||||
|
{
|
||||||
|
var url = match.Value.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':');
|
||||||
|
if (!urls.Contains(url))
|
||||||
|
urls.Add(url);
|
||||||
|
|
||||||
|
if (urls.Count >= HubConstants.EmbedMaxUrlsPerMessage)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPrivateHost(Uri uri)
|
||||||
|
{
|
||||||
|
if (uri.IsLoopback)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (IPAddress.TryParse(uri.Host, out var ip))
|
||||||
|
{
|
||||||
|
var bytes = ip.GetAddressBytes();
|
||||||
|
if (bytes.Length == 4)
|
||||||
|
{
|
||||||
|
if (bytes[0] == 10) return true;
|
||||||
|
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
|
||||||
|
if (bytes[0] == 192 && bytes[1] == 168) return true;
|
||||||
|
if (bytes[0] == 127) return true;
|
||||||
|
if (bytes[0] == 0) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check hostname-based loopback
|
||||||
|
if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseOgTags(string html)
|
||||||
|
{
|
||||||
|
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Match: <meta property="og:key" content="value" />
|
||||||
|
// Groups: 1=prop quote, 2=key, 3=content quote, 4=value
|
||||||
|
foreach (Match match in OgTagRegex().Matches(html))
|
||||||
|
{
|
||||||
|
var key = match.Groups[2].Value;
|
||||||
|
var value = match.Groups[4].Value;
|
||||||
|
tags.TryAdd(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match reversed order: <meta content="value" property="og:key" />
|
||||||
|
// Groups: 1=content quote, 2=value, 3=prop quote, 4=key
|
||||||
|
foreach (Match match in OgTagReversedRegex().Matches(html))
|
||||||
|
{
|
||||||
|
var value = match.Groups[2].Value;
|
||||||
|
var key = match.Groups[4].Value;
|
||||||
|
tags.TryAdd(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadLimitedAsync(HttpResponseMessage response, int maxBytes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||||
|
var buffer = new byte[maxBytes];
|
||||||
|
var totalRead = 0;
|
||||||
|
|
||||||
|
while (totalRead < maxBytes)
|
||||||
|
{
|
||||||
|
var read = await stream.ReadAsync(buffer.AsMemory(totalRead, maxBytes - totalRead), ct);
|
||||||
|
if (read == 0) break;
|
||||||
|
totalRead += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect encoding from Content-Type, default to UTF-8
|
||||||
|
var charset = response.Content.Headers.ContentType?.CharSet;
|
||||||
|
var encoding = charset is not null
|
||||||
|
? Encoding.GetEncoding(charset)
|
||||||
|
: Encoding.UTF8;
|
||||||
|
|
||||||
|
return encoding.GetString(buffer, 0, totalRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex UrlRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*([""'])og:(\w+)\1[^>]*?content\s*=\s*([""'])(.*?)\3[^>]*/?>",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex OgTagRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*([""'])(.*?)\1[^>]*?property\s*=\s*([""'])og:(\w+)\3[^>]*/?>",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex OgTagReversedRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex TitleTagRegex();
|
||||||
|
}
|
||||||
@@ -151,4 +151,27 @@ public class PresenceTracker
|
|||||||
{
|
{
|
||||||
return _userConnections.Count;
|
return _userConnections.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forcibly remove a user from all tracking. Returns their connection IDs and channels
|
||||||
|
/// so the caller can broadcast departures and force-disconnect connections.
|
||||||
|
/// </summary>
|
||||||
|
public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var channels = _userChannels.TryRemove(username, out var ch)
|
||||||
|
? ch.ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
var connectionIds = _userConnections.TryRemove(username, out var conns)
|
||||||
|
? conns.ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
foreach (var connId in connectionIds)
|
||||||
|
_connections.TryRemove(connId, out _);
|
||||||
|
|
||||||
|
return (connectionIds, channels);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,87 +2,122 @@ using Microsoft.AspNetCore.SignalR.Client;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
public sealed class ServerDirectoryService(
|
public sealed class ServerDirectoryService : BackgroundService
|
||||||
IConfiguration configuration,
|
|
||||||
PresenceTracker presenceTracker,
|
|
||||||
ILogger<ServerDirectoryService> logger) : BackgroundService
|
|
||||||
{
|
{
|
||||||
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
||||||
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
|
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
|
||||||
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
|
||||||
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
|
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly ILogger<ServerDirectoryService> _logger;
|
||||||
|
|
||||||
private HubConnection? _connection;
|
private HubConnection? _connection;
|
||||||
private int _lastReportedUserCount = -1;
|
private int _lastReportedUserCount = -1;
|
||||||
|
|
||||||
|
public ServerDirectoryService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
PresenceTracker presenceTracker,
|
||||||
|
ILogger<ServerDirectoryService> logger)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
// Yield to let the host finish starting before we log or connect
|
// Yield to let the host finish starting before we log or connect
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
|
|
||||||
var isPublic = configuration.GetValue<bool>("Server:PublicServer");
|
var isPublic = _configuration.GetValue<bool>("Server:PublicServer");
|
||||||
if (!isPublic)
|
if (!isPublic)
|
||||||
{
|
{
|
||||||
logger.LogInformation("PublicServer is disabled — not registering with directory");
|
_logger.LogInformation("PublicServer is disabled — not registering with directory");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var host = configuration["Server:PublicHost"];
|
var host = _configuration["Server:PublicHost"];
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(host))
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
{
|
{
|
||||||
logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
|
_logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var serverName = configuration["Server:Name"] ?? "EchoHub Server";
|
var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
|
||||||
var description = configuration["Server:Description"];
|
var description = _configuration["Server:Description"];
|
||||||
|
|
||||||
logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
|
_logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
|
||||||
|
|
||||||
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
|
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await using var connection = BuildConnection();
|
var connection = BuildConnection();
|
||||||
_connection = connection;
|
_connection = connection;
|
||||||
|
|
||||||
var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
try
|
||||||
|
|
||||||
connection.Reconnected += async _ =>
|
|
||||||
{
|
{
|
||||||
logger.LogInformation("Reconnected to directory — re-registering server");
|
var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_lastReportedUserCount = -1;
|
|
||||||
|
connection.On("Ping", async () =>
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Received alive check from directory — sending heartbeat");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.InvokeAsync("Heartbeat");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to send heartbeat response");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.Reconnected += async _ =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Reconnected to directory — re-registering server");
|
||||||
|
_lastReportedUserCount = -1;
|
||||||
|
await RegisterAsync(serverName, description, host);
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.Closed += ex =>
|
||||||
|
{
|
||||||
|
if (ex is not null)
|
||||||
|
_logger.LogWarning(ex, "Directory connection permanently closed — will rebuild");
|
||||||
|
else
|
||||||
|
_logger.LogWarning("Directory connection permanently closed — will rebuild");
|
||||||
|
|
||||||
|
connectionPermanentlyClosed.TrySetResult();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect with retry
|
||||||
|
if (!await ConnectWithRetryAsync(connection, stoppingToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
||||||
await RegisterAsync(serverName, description, host);
|
await RegisterAsync(serverName, description, host);
|
||||||
};
|
|
||||||
|
|
||||||
connection.Closed += ex =>
|
// Poll user count until the connection is permanently closed or cancellation
|
||||||
|
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
||||||
|
|
||||||
|
if (stoppingToken.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Connection was permanently closed — wait briefly then rebuild
|
||||||
|
_logger.LogInformation("Rebuilding directory connection...");
|
||||||
|
await Task.Delay(ReconnectBaseDelay, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
if (ex is not null)
|
|
||||||
logger.LogWarning(ex, "Directory connection permanently closed — will rebuild");
|
|
||||||
else
|
|
||||||
logger.LogWarning("Directory connection permanently closed — will rebuild");
|
|
||||||
|
|
||||||
connectionPermanentlyClosed.TrySetResult();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connect with retry
|
|
||||||
if (!await ConnectWithRetryAsync(connection, stoppingToken))
|
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
finally
|
||||||
await RegisterAsync(serverName, description, host);
|
{
|
||||||
|
_connection = null;
|
||||||
// Poll user count until the connection is permanently closed or cancellation
|
await DisposeConnectionAsync(connection);
|
||||||
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
}
|
||||||
|
|
||||||
if (stoppingToken.IsCancellationRequested)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Connection was permanently closed — wait briefly then rebuild
|
|
||||||
_connection = null;
|
|
||||||
logger.LogInformation("Rebuilding directory connection...");
|
|
||||||
await Task.Delay(ReconnectBaseDelay, stoppingToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +143,7 @@ public sealed class ServerDirectoryService(
|
|||||||
{
|
{
|
||||||
attempt++;
|
attempt++;
|
||||||
var delay = GetBackoffDelay(attempt);
|
var delay = GetBackoffDelay(attempt);
|
||||||
logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds);
|
_logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds);
|
||||||
await Task.Delay(delay, ct);
|
await Task.Delay(delay, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,19 +168,17 @@ public sealed class ServerDirectoryService(
|
|||||||
if (connection.State != HubConnectionState.Connected)
|
if (connection.State != HubConnectionState.Connected)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var currentCount = presenceTracker.GetOnlineUserCount();
|
var currentCount = _presenceTracker.GetOnlineUserCount();
|
||||||
if (currentCount == _lastReportedUserCount)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
||||||
_lastReportedUserCount = currentCount;
|
_lastReportedUserCount = currentCount;
|
||||||
logger.LogDebug("Updated directory user count to {Count}", currentCount);
|
_logger.LogDebug("Updated directory user count to {Count}", currentCount);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to update user count on directory");
|
_logger.LogWarning(ex, "Failed to update user count on directory");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,27 +196,35 @@ public sealed class ServerDirectoryService(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userCount = presenceTracker.GetOnlineUserCount();
|
var userCount = _presenceTracker.GetOnlineUserCount();
|
||||||
var dto = new RegisterServerDto(name, description, host, userCount);
|
var dto = new RegisterServerDto(name, description, host, userCount);
|
||||||
await _connection.InvokeAsync("RegisterServer", dto);
|
await _connection.InvokeAsync("RegisterServer", dto);
|
||||||
_lastReportedUserCount = userCount;
|
_lastReportedUserCount = userCount;
|
||||||
logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
|
_logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to register with directory");
|
_logger.LogWarning(ex, "Failed to register with directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DisposeConnectionAsync(HubConnection connection)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.DisposeAsync()
|
||||||
|
.AsTask().WaitAsync(TimeSpan.FromSeconds(3));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Don't let a slow dispose block shutdown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_connection is not null)
|
|
||||||
{
|
|
||||||
await _connection.DisposeAsync();
|
|
||||||
_connection = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await base.StopAsync(cancellationToken);
|
await base.StopAsync(cancellationToken);
|
||||||
|
_connection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -5,49 +5,81 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
public class SignalRBroadcaster(
|
public class SignalRBroadcaster : IChatBroadcaster
|
||||||
IHubContext<ChatHub, IEchoHubClient> hubContext,
|
|
||||||
PresenceTracker presenceTracker) : IChatBroadcaster
|
|
||||||
{
|
{
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private IHubContext<ChatHub, IEchoHubClient>? _hubContext;
|
||||||
|
|
||||||
|
private IHubContext<ChatHub, IEchoHubClient> HubContext
|
||||||
|
=> _hubContext ??= _serviceProvider.GetRequiredService<IHubContext<ChatHub, IEchoHubClient>>();
|
||||||
|
|
||||||
|
public SignalRBroadcaster(IServiceProvider serviceProvider, PresenceTracker presenceTracker)
|
||||||
|
{
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
}
|
||||||
|
|
||||||
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
=> hubContext.Clients.Group(channelName).ReceiveMessage(message);
|
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
||||||
|
|
||||||
public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
|
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
|
||||||
return hubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
|
return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
|
||||||
|
|
||||||
return hubContext.Clients.Group(channelName).UserJoined(channelName, username);
|
return HubContext.Clients.Group(channelName).UserJoined(channelName, username);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendUserLeftAsync(string channelName, string username)
|
public Task SendUserLeftAsync(string channelName, string username)
|
||||||
=> hubContext.Clients.Group(channelName).UserLeft(channelName, username);
|
=> HubContext.Clients.Group(channelName).UserLeft(channelName, username);
|
||||||
|
|
||||||
public Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
public Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
||||||
{
|
{
|
||||||
if (channelName is not null)
|
if (channelName is not null)
|
||||||
return hubContext.Clients.Group(channelName).ChannelUpdated(channel);
|
return HubContext.Clients.Group(channelName).ChannelUpdated(channel);
|
||||||
|
|
||||||
return hubContext.Clients.All.ChannelUpdated(channel);
|
return HubContext.Clients.All.ChannelUpdated(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence)
|
public Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence)
|
||||||
{
|
{
|
||||||
var connections = presenceTracker.GetConnectionsInChannels(channelNames)
|
var connections = _presenceTracker.GetConnectionsInChannels(channelNames)
|
||||||
.Where(c => !c.StartsWith("irc-"))
|
.Where(c => !c.StartsWith("irc-"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (connections.Count == 0)
|
if (connections.Count == 0)
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return hubContext.Clients.Clients(connections).UserStatusChanged(presence);
|
return HubContext.Clients.Clients(connections).UserStatusChanged(presence);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SendUserKickedAsync(string channelName, string username, string? reason)
|
||||||
|
=> HubContext.Clients.Group(channelName).UserKicked(channelName, username, reason);
|
||||||
|
|
||||||
|
public Task SendUserBannedAsync(string username, string? reason)
|
||||||
|
=> HubContext.Clients.All.UserBanned(username, reason);
|
||||||
|
|
||||||
|
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
||||||
|
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
|
||||||
|
|
||||||
|
public Task SendChannelNukedAsync(string channelName)
|
||||||
|
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
|
||||||
|
|
||||||
public Task SendErrorAsync(string connectionId, string message)
|
public Task SendErrorAsync(string connectionId, string message)
|
||||||
{
|
{
|
||||||
if (connectionId.StartsWith("irc-"))
|
if (connectionId.StartsWith("irc-"))
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
return hubContext.Clients.Client(connectionId).Error(message);
|
return HubContext.Clients.Client(connectionId).Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ForceDisconnectUserAsync(List<string> connectionIds, string reason)
|
||||||
|
{
|
||||||
|
var signalRIds = connectionIds.Where(c => !c.StartsWith("irc-")).ToList();
|
||||||
|
if (signalRIds.Count == 0)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
return HubContext.Clients.Clients(signalRIds).ForceDisconnect(reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Setup;
|
||||||
|
|
||||||
|
public static partial class DataMigrationService
|
||||||
|
{
|
||||||
|
public static async Task RunAsync(IServiceProvider services)
|
||||||
|
{
|
||||||
|
using var scope = services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("EchoHub.Server.Setup.DataMigration");
|
||||||
|
|
||||||
|
await EnsureDefaultChannelsPublicAsync(db, logger);
|
||||||
|
await MigrateAnsiMessagesAsync(db, logger);
|
||||||
|
await MigrateEmbedJsonToArrayAsync(db, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure the #general channel (and any pre-existing channels from before the IsPublic column) are public.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task EnsureDefaultChannelsPublicAsync(EchoHubDbContext db, ILogger logger)
|
||||||
|
{
|
||||||
|
var general = await db.Channels.FirstOrDefaultAsync(c => c.Name == HubConstants.DefaultChannel);
|
||||||
|
if (general is not null && !general.IsPublic)
|
||||||
|
{
|
||||||
|
general.IsPublic = true;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Marked #{Channel} as public.", HubConstants.DefaultChannel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task MigrateAnsiMessagesAsync(EchoHubDbContext db, ILogger logger)
|
||||||
|
{
|
||||||
|
// Load messages that contain the ESC byte (0x1B) — these have legacy ANSI color codes.
|
||||||
|
// Filter by Image type first (only images have ANSI art), then check content in memory.
|
||||||
|
var messages = await db.Messages
|
||||||
|
.Where(m => m.Type == Core.Models.MessageType.Image)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var toMigrate = messages.Where(m => m.Content.Contains('\x1b')).ToList();
|
||||||
|
|
||||||
|
if (toMigrate.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
logger.LogInformation("Found {Count} messages with legacy ANSI color codes. Migrating to color tag format...", toMigrate.Count);
|
||||||
|
|
||||||
|
var modified = 0;
|
||||||
|
foreach (var message in toMigrate)
|
||||||
|
{
|
||||||
|
var converted = AnsiToColorTags(message.Content);
|
||||||
|
if (converted != message.Content)
|
||||||
|
{
|
||||||
|
message.Content = converted;
|
||||||
|
modified++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Migrated {Count} messages from ANSI escape codes to printable color tags.", modified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert ANSI escape codes to printable color tags.
|
||||||
|
/// \x1b[38;2;R;G;Bm → {F:RRGGBB}, \x1b[48;2;R;G;Bm → {B:RRGGBB}, \x1b[0m → {X}
|
||||||
|
/// </summary>
|
||||||
|
public static string AnsiToColorTags(string text)
|
||||||
|
{
|
||||||
|
return AnsiColorRegex().Replace(text, match =>
|
||||||
|
{
|
||||||
|
if (match.Groups[1].Value == "0")
|
||||||
|
return "{X}";
|
||||||
|
|
||||||
|
if (match.Groups[2].Success)
|
||||||
|
{
|
||||||
|
var r = int.Parse(match.Groups[3].Value);
|
||||||
|
var g = int.Parse(match.Groups[4].Value);
|
||||||
|
var b = int.Parse(match.Groups[5].Value);
|
||||||
|
var type = match.Groups[2].Value == "38;2" ? "F" : "B";
|
||||||
|
return $"{{{type}:{r:X2}{g:X2}{b:X2}}}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return match.Value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||||
|
private static partial Regex AnsiColorRegex();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
|
||||||
|
/// </summary>
|
||||||
|
private static async Task MigrateEmbedJsonToArrayAsync(EchoHubDbContext db, ILogger logger)
|
||||||
|
{
|
||||||
|
var messages = await db.Messages
|
||||||
|
.Where(m => m.EmbedJson != null)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var toMigrate = messages
|
||||||
|
.Where(m => m.EmbedJson!.TrimStart().StartsWith('{'))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (toMigrate.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
logger.LogInformation("Found {Count} messages with legacy single-embed JSON. Migrating to array format...", toMigrate.Count);
|
||||||
|
|
||||||
|
var modified = 0;
|
||||||
|
foreach (var message in toMigrate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var single = System.Text.Json.JsonSerializer.Deserialize<EmbedDto>(message.EmbedJson!);
|
||||||
|
if (single is not null)
|
||||||
|
{
|
||||||
|
message.EmbedJson = System.Text.Json.JsonSerializer.Serialize(new[] { single });
|
||||||
|
modified++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Skip malformed JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ public static class DatabaseSetup
|
|||||||
|
|
||||||
await MigrateAsync(db, logger);
|
await MigrateAsync(db, logger);
|
||||||
await SeedDefaultChannelAsync(db, logger);
|
await SeedDefaultChannelAsync(db, logger);
|
||||||
|
|
||||||
|
// Run data migrations (e.g. ANSI → color tag format)
|
||||||
|
await DataMigrationService.RunAsync(services);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger)
|
private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger)
|
||||||
|
|||||||
Reference in New Issue
Block a user