Файлы, правки отображения
This commit is contained in:
@@ -1,39 +1,41 @@
|
||||
using Knot.Modules.Messaging;
|
||||
using Knot.Shared.Infrastructure;
|
||||
using Knot.Modules.Auth;
|
||||
using Knot.Modules.Auth.Presentation.Endpoints;
|
||||
using Knot.Modules.Profiles;
|
||||
using Knot.Modules.Profiles.Presentation.Endpoints;
|
||||
using Knot.Modules.Settings;
|
||||
using Knot.Modules.Settings.Presentation.Endpoints;
|
||||
using Knot.Modules.Admin;
|
||||
using Knot.Modules.Conversations;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Modules.Conversations.Presentation.Endpoints;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Modules.Storage;
|
||||
using Knot.Modules.Stories;
|
||||
using Knot.Modules.Stories.Presentation.Endpoints;
|
||||
using Knot.Modules.Klipy;
|
||||
using Knot.Modules.Federation;
|
||||
using Knot.Modules.WebRtc;
|
||||
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
||||
using Knot.Modules.TelegramImport;
|
||||
using Knot.Modules.TelegramImport.Presentation.Endpoints;
|
||||
using Knot.Modules.Relations;
|
||||
using Knot.Modules.Relations.Presentation.Endpoints;
|
||||
using Host.Endpoints;
|
||||
using Knot.Host.Presentation.Endpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Knot.Modules.Admin;
|
||||
using Knot.Modules.Admin.Presentation.Endpoints;
|
||||
using Knot.Modules.Auth;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Knot.Modules.Auth.Presentation.Endpoints;
|
||||
using Knot.Modules.Conversations;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Modules.Conversations.Presentation.Endpoints;
|
||||
using Knot.Modules.Federation;
|
||||
using Knot.Modules.Federation.Presentation.Endpoints;
|
||||
using Knot.Modules.Klipy;
|
||||
using Knot.Modules.Klipy.Presentation.Endpoints;
|
||||
using Knot.Modules.Messaging;
|
||||
using Knot.Modules.Profiles;
|
||||
using Knot.Modules.Profiles.Presentation.Endpoints;
|
||||
using Knot.Modules.Relations;
|
||||
using Knot.Modules.Relations.Presentation.Endpoints;
|
||||
using Knot.Modules.Settings;
|
||||
using Knot.Modules.Settings.Presentation.Endpoints;
|
||||
using Knot.Modules.Storage;
|
||||
using Knot.Modules.Storage.Presentation.Endpoints;
|
||||
using Knot.Modules.Stories;
|
||||
using Knot.Modules.Stories.Presentation.Endpoints;
|
||||
using Knot.Modules.TelegramImport;
|
||||
using Knot.Modules.TelegramImport.Presentation.Endpoints;
|
||||
using Knot.Modules.WebRtc;
|
||||
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
||||
using Knot.Shared.Infrastructure;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +74,7 @@ builder.Services.AddSettingsModule(builder.Configuration);
|
||||
builder.Services.AddMessagingModule(builder.Configuration);
|
||||
builder.Services.AddConversationsModule(builder.Configuration);
|
||||
builder.Services.AddProfilesModule(builder.Configuration);
|
||||
builder.Services.AddRelationsModule(builder.Configuration);
|
||||
builder.Services.AddStorageModule(builder.Configuration);
|
||||
builder.Services.AddStoriesModule(builder.Configuration);
|
||||
builder.Services.AddKlipyModule();
|
||||
@@ -86,7 +89,8 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||
typeof(Knot.Modules.Messaging.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Conversations.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Stories.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly
|
||||
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly,
|
||||
typeof(Knot.Modules.Relations.DependencyInjection).Assembly
|
||||
));
|
||||
|
||||
// Настройка CORS
|
||||
@@ -96,9 +100,9 @@ builder.Services.AddCors(options =>
|
||||
{
|
||||
var originsFromConfig = builder.Configuration["Cors:Origins"];
|
||||
var domain = builder.Configuration["DOMAIN"];
|
||||
|
||||
var origins = !string.IsNullOrEmpty(originsFromConfig)
|
||||
? originsFromConfig.Split(',')
|
||||
|
||||
var origins = !string.IsNullOrEmpty(originsFromConfig)
|
||||
? originsFromConfig.Split(',')
|
||||
: (!string.IsNullOrEmpty(domain) ? new[] { $"https://{domain}" } : new[] { "*" });
|
||||
|
||||
var corsBuilder = policy.WithOrigins(origins)
|
||||
@@ -120,7 +124,7 @@ builder.Services.AddCors(options =>
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
// Настройка маршрутизации
|
||||
builder.Services.AddRouting(options =>
|
||||
builder.Services.AddRouting(options =>
|
||||
{
|
||||
options.LowercaseUrls = true;
|
||||
options.LowercaseQueryStrings = true;
|
||||
@@ -184,7 +188,7 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var identityDb = scope.ServiceProvider.GetRequiredService<AuthDbContext>();
|
||||
await identityDb.Database.MigrateAsync();
|
||||
|
||||
|
||||
var chatsDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Conversations.Infrastructure.Persistence.ChatsDbContext>();
|
||||
await chatsDb.Database.MigrateAsync();
|
||||
|
||||
@@ -194,6 +198,9 @@ using (var scope = app.Services.CreateScope())
|
||||
var storiesDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Stories.Infrastructure.Database.StoriesDbContext>();
|
||||
await storiesDb.Database.MigrateAsync();
|
||||
|
||||
var relationsDb = scope.ServiceProvider.GetRequiredService<Knot.Modules.Relations.Infrastructure.Persistence.RelationsDbContext>();
|
||||
await relationsDb.Database.MigrateAsync();
|
||||
|
||||
// Set Encryption Service for MongoDB serializers
|
||||
var encryptionService = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Security.IEncryptionService>();
|
||||
Knot.Modules.Messaging.Infrastructure.Persistence.Mongo.EncryptedStringSerializer.EncryptionService = encryptionService;
|
||||
|
||||
@@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Knot.Host.Presentation.Endpoints;
|
||||
namespace Knot.Modules.Admin.Presentation.Endpoints;
|
||||
|
||||
public record KlipyTestDto(
|
||||
[property: JsonPropertyName("apiKey")] string ApiKey,
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Knot.Modules.Conversations.Application.DTOs;
|
||||
public record MediaDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Url,
|
||||
string? Url,
|
||||
string? Filename,
|
||||
long? Size
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.IO;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure;
|
||||
|
||||
@@ -10,8 +12,24 @@ public class ChatsDbContextFactory : IDesignTimeDbContextFactory<ChatsDbContext>
|
||||
{
|
||||
public ChatsDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var basePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "Host");
|
||||
if (!Directory.Exists(basePath))
|
||||
{
|
||||
basePath = Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ChatsDbContext>();
|
||||
optionsBuilder.UseNpgsql("Host=localhost;Database=knot_db;Username=knot;Password=knot_pass");
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
?? configuration["DATABASE_URL"]
|
||||
?? "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass";
|
||||
|
||||
optionsBuilder.UseNpgsql(connectionString);
|
||||
return new ChatsDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Host.Endpoints;
|
||||
namespace Knot.Modules.Federation.Presentation.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Эндпоинты федерации для межсерверного взаимодействия.
|
||||
|
||||
@@ -11,7 +11,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
|
||||
namespace Host.Application.Klipy.Queries;
|
||||
namespace Knot.Modules.Klipy.Application.Klipy.Queries;
|
||||
|
||||
public record GetTrendingGifsQuery : IQuery<JsonElement?>;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediatR;
|
||||
|
||||
namespace Host.Application.Klipy.Queries;
|
||||
namespace Knot.Modules.Klipy.Application.Klipy.Queries;
|
||||
|
||||
public record SearchGifsQuery(string Query) : IQuery<JsonElement?>;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Host.Endpoints;
|
||||
namespace Knot.Modules.Klipy.Presentation.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Регистрация эндпоинтов для сервиса Klipy.
|
||||
@@ -20,13 +20,13 @@ public static class KlipyEndpoints
|
||||
|
||||
group.MapGet("/trending", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new Host.Application.Klipy.Queries.GetTrendingGifsQuery(), ct);
|
||||
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetTrendingGifsQuery(), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
|
||||
group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new Host.Application.Klipy.Queries.SearchGifsQuery(q), ct);
|
||||
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.SearchGifsQuery(q), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class DependencyInjection
|
||||
services.AddDbContext<RelationsDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
services.AddScoped<IFriendshipRepository, FriendshipRepository>();
|
||||
services.AddScoped<Knot.Contracts.Relations.Application.Abstractions.IFriendshipRepository, FriendshipRepository>();
|
||||
|
||||
services.AddMediatR(config =>
|
||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Modules.Relations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Knot.Modules.Relations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Knot.Modules.Relations.Infrastructure.Persistence;
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class RelationsDbContext : DbContext
|
||||
|
||||
public DbSet<Contact> Contacts => Set<Contact>();
|
||||
public DbSet<UserReplica> UserReplicas => Set<UserReplica>();
|
||||
public DbSet<Friendship> Friendships => Set<Friendship>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -40,6 +41,14 @@ public sealed class RelationsDbContext : DbContext
|
||||
builder.ToTable("UserReplicas");
|
||||
builder.HasKey(r => r.Id);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Friendship>(builder =>
|
||||
{
|
||||
builder.ToTable("Friendships");
|
||||
builder.HasKey(f => f.Id);
|
||||
builder.Property(f => f.Status).HasConversion<string>();
|
||||
builder.HasIndex(f => new { f.UserId, f.FriendId }).IsUnique();
|
||||
});
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.IO;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Knot.Modules.Relations.Infrastructure.Persistence;
|
||||
|
||||
public class RelationsDbContextFactory : IDesignTimeDbContextFactory<RelationsDbContext>
|
||||
{
|
||||
public RelationsDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var basePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "Host");
|
||||
if (!Directory.Exists(basePath))
|
||||
{
|
||||
basePath = Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<RelationsDbContext>();
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
?? configuration["DATABASE_URL"]
|
||||
?? "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass";
|
||||
|
||||
optionsBuilder.UseNpgsql(connectionString);
|
||||
|
||||
return new RelationsDbContext(optionsBuilder.Options, null!);
|
||||
}
|
||||
}
|
||||
114
backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.Designer.cs
generated
Normal file
114
backend/src/Modules/Relations/Migrations/20260401112051_AddFriendships.Designer.cs
generated
Normal file
@@ -0,0 +1,114 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Relations.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Relations.Migrations
|
||||
{
|
||||
[DbContext(typeof(RelationsDbContext))]
|
||||
[Migration("20260401112051_AddFriendships")]
|
||||
partial class AddFriendships
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("relations")
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Relations.Domain.Contact", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "ContactId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Contacts", "relations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Relations.Domain.Friendship", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("FriendId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "FriendId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Friendships", "relations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Relations.Domain.UserReplica", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("UserReplicas", "relations");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Relations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFriendships : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "relations");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Contacts",
|
||||
schema: "relations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ContactId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Contacts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Friendships",
|
||||
schema: "relations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FriendId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Friendships", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserReplicas",
|
||||
schema: "relations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Username = table.Column<string>(type: "text", nullable: false),
|
||||
DisplayName = table.Column<string>(type: "text", nullable: false),
|
||||
Avatar = table.Column<string>(type: "text", nullable: false),
|
||||
IsExternal = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Domain = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserReplicas", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Contacts_UserId_ContactId",
|
||||
schema: "relations",
|
||||
table: "Contacts",
|
||||
columns: new[] { "UserId", "ContactId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Friendships_UserId_FriendId",
|
||||
schema: "relations",
|
||||
table: "Friendships",
|
||||
columns: new[] { "UserId", "FriendId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Contacts",
|
||||
schema: "relations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Friendships",
|
||||
schema: "relations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserReplicas",
|
||||
schema: "relations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Relations.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Relations.Migrations
|
||||
{
|
||||
[DbContext(typeof(RelationsDbContext))]
|
||||
partial class RelationsDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("relations")
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Relations.Domain.Contact", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "ContactId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Contacts", "relations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Relations.Domain.Friendship", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("FriendId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "FriendId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Friendships", "relations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Relations.Domain.UserReplica", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("UserReplicas", "relations");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,43 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Knot.Host.Presentation.Endpoints;
|
||||
namespace Knot.Modules.Storage.Presentation.Endpoints;
|
||||
|
||||
public static class FilesEndpoints
|
||||
{
|
||||
public static void MapFilesEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/files");
|
||||
|
||||
group.MapGet("{id}", async (string id, [FromQuery] bool download, [FromServices] IFileStorageService fileStorage, [FromServices] IMemoryCache cache, CancellationToken ct) =>
|
||||
// Federation remote endpoint - most specific, must be first
|
||||
app.MapGet("/api/files/remote/{domain}/{id}", async (string domain, string id, HttpClient httpClient, ISettingsService settingsService, CancellationToken ct) =>
|
||||
{
|
||||
var settings = await settingsService.GetSettingsAsync(ct);
|
||||
if (!settings.Federation.Enabled) return Results.Forbid();
|
||||
|
||||
var targetUrl = $"{domain.TrimEnd('/')}/api/federation/v1/proxy/{id}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, targetUrl);
|
||||
request.Headers.Add("X-Knot-Domain", settings.System.DomainUrl);
|
||||
|
||||
using (var rsa = RSA.Create())
|
||||
{
|
||||
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _);
|
||||
var dataToSign = Encoding.UTF8.GetBytes(id + settings.System.DomainUrl);
|
||||
var signature = Convert.ToBase64String(rsa.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
request.Headers.Add("X-Knot-Signature", signature);
|
||||
}
|
||||
|
||||
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (!response.IsSuccessStatusCode) return Results.NotFound();
|
||||
|
||||
var remoteStream = await response.Content.ReadAsStreamAsync(ct);
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
||||
|
||||
return Results.Stream(remoteStream, contentType, enableRangeProcessing: true);
|
||||
});
|
||||
|
||||
// Main file download endpoint - without group prefix to avoid routing issues
|
||||
app.MapGet("/api/files/{id}", async ([FromRoute] string id, [FromServices] IFileStorageService fileStorage, [FromServices] IMemoryCache cache, HttpContext httpContext, [FromQuery] bool download = false, CancellationToken ct = default) =>
|
||||
{
|
||||
// (Текущая логика локальных файлов остается без изменений)
|
||||
try
|
||||
{
|
||||
var cacheKey = $"file_{id}";
|
||||
@@ -50,39 +76,9 @@ public static class FilesEndpoints
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.NotFound(new { error = "Файл не найден или ошибка доступа.", message = ex.Message });
|
||||
return Results.NotFound(new { error = "Файл не найден", message = ex.Message });
|
||||
}
|
||||
});
|
||||
|
||||
// Новый эндпоинт для удаленных (федеративных) файлов
|
||||
group.MapGet("/remote/{domain}/{id}", async (string domain, string id, HttpClient httpClient, ISettingsService settingsService, CancellationToken ct) =>
|
||||
{
|
||||
var settings = await settingsService.GetSettingsAsync(ct);
|
||||
if (!settings.Federation.Enabled) return Results.Forbid();
|
||||
|
||||
// Формируем подписанный запрос к удаленному серверу
|
||||
var targetUrl = $"{domain.TrimEnd('/')}/api/federation/v1/proxy/{id}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, targetUrl);
|
||||
request.Headers.Add("X-Knot-Domain", settings.System.DomainUrl);
|
||||
|
||||
// Генерируем подпись запроса своим приватным ключом
|
||||
using (var rsa = RSA.Create())
|
||||
{
|
||||
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(settings.Federation.PrivateKey!), out _);
|
||||
var dataToSign = Encoding.UTF8.GetBytes(id + settings.System.DomainUrl);
|
||||
var signature = Convert.ToBase64String(rsa.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||||
request.Headers.Add("X-Knot-Signature", signature);
|
||||
}
|
||||
|
||||
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (!response.IsSuccessStatusCode) return Results.NotFound();
|
||||
|
||||
var remoteStream = await response.Content.ReadAsStreamAsync(ct);
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
||||
|
||||
return Results.Stream(remoteStream, contentType, enableRangeProcessing: true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http;
|
||||
using Knot.Contracts.Stories.Infrastructure.Persistence;
|
||||
using Knot.Modules.Stories.Application.Abstractions;
|
||||
using Knot.Modules.Stories.Infrastructure.Database;
|
||||
using Knot.Modules.Stories.Infrastructure.Persistence.Mongo;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -14,6 +15,7 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddScoped<IStoryRepository, StoryRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<Knot.Contracts.Stories.Infrastructure.Persistence.IStoryCollection, StoryCollection>();
|
||||
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.IO;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Knot.Modules.Stories.Infrastructure.Database;
|
||||
|
||||
public class StoriesDbContextFactory : IDesignTimeDbContextFactory<StoriesDbContext>
|
||||
{
|
||||
public StoriesDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var basePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "Host");
|
||||
if (!Directory.Exists(basePath))
|
||||
{
|
||||
basePath = Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<StoriesDbContext>();
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
?? configuration["DATABASE_URL"]
|
||||
?? "Host=localhost;Port=5432;Database=knot_db;Username=knot;Password=knot_pass";
|
||||
|
||||
optionsBuilder.UseNpgsql(connectionString);
|
||||
|
||||
return new StoriesDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Contracts.Relations.Domain;
|
||||
using Knot.Modules.Stories.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Stories.Infrastructure.Database;
|
||||
|
||||
internal sealed class UserRepository : IUserRepository
|
||||
{
|
||||
private readonly IProfileRepository _profileRepository;
|
||||
|
||||
public UserRepository(IProfileRepository profileRepository)
|
||||
{
|
||||
_profileRepository = profileRepository;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await _profileRepository.GetAsync(id, cancellationToken);
|
||||
return profile != null;
|
||||
}
|
||||
|
||||
public async Task<UserReplica?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await _profileRepository.GetAsync(id, cancellationToken);
|
||||
if (profile == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new UserReplica(
|
||||
profile.UserId,
|
||||
profile.Username ?? string.Empty,
|
||||
profile.DisplayName ?? string.Empty,
|
||||
profile.Avatar ?? string.Empty,
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Relations\Knot.Contracts.Relations.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Settings\Knot.Contracts.Settings.csproj" />
|
||||
|
||||
54
backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.Designer.cs
generated
Normal file
54
backend/src/Modules/Stories/Migrations/20260401112239_InitialStories.Designer.cs
generated
Normal file
@@ -0,0 +1,54 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Stories.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Stories.Migrations
|
||||
{
|
||||
[DbContext(typeof(StoriesDbContext))]
|
||||
[Migration("20260401112239_InitialStories")]
|
||||
partial class InitialStories
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("stories")
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Stories.Domain.StoryViewer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("StoryId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("ViewedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StoryId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("StoryViewers", "stories");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Stories.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialStories : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "stories");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StoryViewers",
|
||||
schema: "stories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
StoryId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ViewedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StoryViewers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoryViewers_StoryId_UserId",
|
||||
schema: "stories",
|
||||
table: "StoryViewers",
|
||||
columns: new[] { "StoryId", "UserId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "StoryViewers",
|
||||
schema: "stories");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Knot.Modules.Stories.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Stories.Migrations
|
||||
{
|
||||
[DbContext(typeof(StoriesDbContext))]
|
||||
partial class StoriesDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("stories")
|
||||
.HasAnnotation("ProductVersion", "10.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Stories.Domain.StoryViewer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("StoryId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("ViewedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StoryId", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("StoryViewers", "stories");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,16 +430,16 @@ function MessageBubble({
|
||||
!needsFrame
|
||||
? 'p-0 shadow-none border-none bg-transparent'
|
||||
: isMine
|
||||
? 'bubble-sent px-4 py-2.5 hover:bg-primary/[0.12] rounded-2xl rounded-br-[4px]'
|
||||
: 'bubble-received px-4 py-2.5 hover:bg-surface-container-highest/80 rounded-2xl rounded-bl-[4px]'
|
||||
? 'bubble-sent px-4 py-3 hover:brightness-110'
|
||||
: 'bubble-received px-4 py-3 hover:brightness-110'
|
||||
}`}
|
||||
>
|
||||
|
||||
{/* Reply */}
|
||||
{message.replyTo && (
|
||||
{message.replyTo && (
|
||||
<div
|
||||
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] cursor-pointer transition-colors -mx-1 px-1 rounded-sm ${
|
||||
isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
|
||||
className={`mb-2 pl-3 py-2 cursor-pointer transition-all -mx-1 px-2 rounded-xl ${
|
||||
isMine ? 'bg-[#1a1a1a] border-l-[3px] border-l-primary hover:bg-[#202020]' : 'bg-white/5 border-l-[3px] border-l-primary hover:bg-white/10'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -451,12 +451,12 @@ function MessageBubble({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className={`text-[13px] font-bold mb-0.5 truncate ${isMine ? 'text-primary' : 'text-primary'}`}>
|
||||
<p className={`text-[12px] font-black uppercase tracking-wider mb-0.5 truncate ${isMine ? 'text-primary' : 'text-primary'}`}>
|
||||
{message.replyTo.sender?.displayName || message.replyTo.sender?.userName || message.replyTo.sender?.username || ''}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{message.replyTo.isDeleted ? (
|
||||
<p className="text-[13px] text-white/50 italic truncate">{t('messageDeleted')}</p>
|
||||
<p className="text-[13px] text-zinc-500 italic truncate">{t('messageDeleted')}</p>
|
||||
) : (
|
||||
<>
|
||||
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => {
|
||||
@@ -466,13 +466,13 @@ function MessageBubble({
|
||||
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0 relative">
|
||||
{m.type === 'image' ? (
|
||||
isMp4 ? (
|
||||
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
|
||||
<video src={getMediaUrl(m.url)} className="w-full h-full object-cover" muted playsInline />
|
||||
) : (
|
||||
<img src={m.url} className="w-full h-full object-cover" alt="" />
|
||||
<img src={getMediaUrl(m.url)} className="w-full h-full object-cover" alt="" />
|
||||
)
|
||||
) : m.type === 'video' ? (
|
||||
<>
|
||||
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
|
||||
<video src={getMediaUrl(m.url)} className="w-full h-full object-cover" muted playsInline />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
|
||||
</>
|
||||
) : (
|
||||
@@ -481,7 +481,7 @@ function MessageBubble({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-on-surface-variant' : 'text-on-surface-variant'}`}>
|
||||
<p className={`text-[13px] line-clamp-1 break-words italic ${isMine ? 'text-zinc-200' : 'text-zinc-400'}`}>
|
||||
{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => {
|
||||
const m = message.replyTo.media[0];
|
||||
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF';
|
||||
@@ -522,7 +522,7 @@ function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
|
||||
<p className={`text-[13px] line-clamp-2 break-words whitespace-pre-wrap ${isMine ? 'text-[#0a0a0a]/70' : 'text-zinc-600 dark:text-zinc-300'}`}>
|
||||
{message.quote}
|
||||
</p>
|
||||
</div>
|
||||
@@ -633,29 +633,27 @@ function MessageBubble({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Голосовое */}
|
||||
{/* Голосовое - Optimized Kinetic Layout */}
|
||||
{hasVoice && (
|
||||
<div className="flex items-center gap-3 min-w-[200px]">
|
||||
<div className="flex items-center gap-3 min-w-[200px] py-0.5">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={media.find((m) => m.type === 'voice')?.url}
|
||||
preload="auto"
|
||||
onError={(e) => console.error('Audio load error:', e)}
|
||||
/>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-knot-500/20 hover:bg-knot-500/30'
|
||||
} transition-colors`}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white text-primary' : 'bg-primary text-white'} shadow-sm transition-all active:scale-95`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} className={isMine ? 'text-white' : 'text-knot-400'} />
|
||||
<Pause size={16} fill="currentColor" />
|
||||
) : (
|
||||
<Play size={16} className={`${isMine ? 'text-white' : 'text-knot-400'} ml-0.5`} />
|
||||
<Play size={16} fill="currentColor" className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex items-end gap-[2px] h-6 cursor-pointer"
|
||||
className="flex items-center gap-[2px] h-6 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audio.duration) return;
|
||||
@@ -667,27 +665,29 @@ function MessageBubble({
|
||||
}}
|
||||
>
|
||||
{(waveformBars || Array(28).fill(0.5)).map((val, i) => {
|
||||
const barHeight = Math.max(10, val * 100);
|
||||
const barHeight = Math.max(10, val * 85);
|
||||
const progress = audioProgress / 100;
|
||||
const barProgress = i / 28;
|
||||
const isActive = barProgress < progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
|
||||
? isMine ? 'bg-primary/40' : 'bg-primary'
|
||||
: isMine ? 'bg-primary/10' : 'bg-on-surface-variant/10'
|
||||
className={`flex-1 rounded-full transition-all duration-200 ${isActive
|
||||
? isMine ? 'bg-[#000000] opacity-70' : 'bg-primary'
|
||||
: isMine ? 'bg-[#000000] opacity-20' : 'bg-white/30'
|
||||
}`}
|
||||
style={{ height: `${barHeight}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
|
||||
{isPlaying
|
||||
? formatDuration(audioRef.current?.currentTime || 0)
|
||||
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
|
||||
</span>
|
||||
<div className="flex justify-end mt-0.5">
|
||||
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-white/60'}`}>
|
||||
{isPlaying
|
||||
? formatDuration(audioRef.current?.currentTime || 0)
|
||||
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -786,7 +786,7 @@ function MessageBubble({
|
||||
return (
|
||||
<div className="flex items-end gap-2 text-sm w-full">
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''}`}>
|
||||
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
|
||||
{renderFormattedText(message.content)}
|
||||
</p>
|
||||
{firstUrl && !hasImage && !hasVideo && !hasFile && (
|
||||
@@ -795,12 +795,12 @@ function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-primary/60' : 'text-on-surface-variant/40'}`}>
|
||||
<span className={`text-[10px] font-bold flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-[#0a0a0a]/50' : 'text-on-surface-variant/40'}`}>
|
||||
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
|
||||
{message.scheduledAt && <span className="material-symbols-outlined text-[12px] text-amber-400 mr-0.5">schedule</span>}
|
||||
{timeStr}
|
||||
{isMine && !message.scheduledAt && (
|
||||
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-primary fill-1' : 'text-on-surface-variant/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
|
||||
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-[#0a0a0a]/80 fill-1' : 'text-[#0a0a0a]/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
|
||||
{isRead ? 'done_all' : 'done'}
|
||||
</span>
|
||||
)}
|
||||
@@ -810,46 +810,31 @@ function MessageBubble({
|
||||
})()}
|
||||
|
||||
|
||||
{/* Реакции */}
|
||||
{Object.keys(reactionGroups).length > 0 && (
|
||||
<div className={`flex flex-wrap gap-1 justify-start ${!message.content && (hasImage || hasVideo) ? 'mt-2 mb-1' : 'mt-1.5'}`}>
|
||||
{Object.entries(reactionGroups).map(([emoji, data]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 ${hasImage && !message.content ? 'backdrop-blur-md bg-black/40 text-white' : (isMine ? 'glass-panel text-white border-white/10 shadow-sm' : 'bg-surface-tertiary text-zinc-200 border-white/5 shadow-sm')} rounded-full transition-colors border ${
|
||||
data.isMine
|
||||
? (isMine ? 'bg-white/20 border-white/20' : 'bg-knot-500/20 border-knot-500/30')
|
||||
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/10')
|
||||
}`}
|
||||
title={data.users.join(', ')}
|
||||
>
|
||||
<span className="text-[17px] leading-none">{emoji}</span>
|
||||
{(data.avatars && data.avatars.length > 0) ? (
|
||||
<div className="flex -space-x-1.5 ml-0.5">
|
||||
{data.avatars.map((av, idx) => (
|
||||
av.url ? (
|
||||
<img key={idx} src={av.url} className="w-5 h-5 rounded-full object-cover shadow-sm" />
|
||||
) : (
|
||||
<div key={idx} className={`w-5 h-5 rounded-full bg-gradient-to-br ${av.colorClass} flex items-center justify-center text-white text-[9px] font-bold shadow-sm`}>
|
||||
{av.initials}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[12px] font-medium opacity-80 tabular-nums">{data.count}</span>
|
||||
)}
|
||||
{data.count > 1 && data.avatars && data.avatars.length > 0 && (
|
||||
<span className="text-[12px] font-bold opacity-80 tabular-nums ml-1.5 mr-0.5">{data.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{Object.keys(reactionGroups).length > 0 && (
|
||||
<div className={`flex flex-wrap gap-1.5 mt-2 ${isMine ? 'justify-end' : 'justify-start'} relative z-20`}>
|
||||
{Object.entries(reactionGroups).map(([emoji, data]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${
|
||||
data.isMine
|
||||
? 'bg-primary/20 border-primary text-white shadow-lg'
|
||||
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
|
||||
} shadow-md group/react`}
|
||||
title={data.users.join(', ')}
|
||||
>
|
||||
<span className="text-[14px] leading-none">{emoji}</span>
|
||||
{data.count > 1 && (
|
||||
<span className="text-[13px] font-bold tabular-nums">{data.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMine && (
|
||||
@@ -877,7 +862,7 @@ function MessageBubble({
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="fixed z-[9999] w-52 rounded-[1.25rem] glass-strong shadow-2xl py-1.5 overflow-hidden border border-white/10"
|
||||
className="fixed z-[9999] w-52 rounded-[1.25rem] bg-[#1a1a1a]/95 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10"
|
||||
style={{ left: contextPos.x, top: contextPos.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
|
||||
@@ -130,8 +130,10 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
el.style.height = '22px'; // Reset/Min height
|
||||
if (text) {
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
}
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
@@ -616,12 +618,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
||||
<p className="text-[11px] font-black tracking-widest uppercase text-primary mb-0.5">
|
||||
<p className="text-[10px] font-bold tracking-widest uppercase text-primary mb-0.5">
|
||||
{editingMessage
|
||||
? t('editing')
|
||||
: `${t('replyTo')} ${replyTo?.sender?.displayName || replyTo?.sender?.userName || replyTo?.sender?.username || ''}`}
|
||||
</p>
|
||||
<div className="text-[13px] text-on-surface-variant truncate opacity-80 pl-1 border-l border-on-surface-variant/10 ml-0.5">
|
||||
<div className="text-[13px] text-[#efeff3] truncate opacity-90 pl-1 border-l border-white/20 ml-0.5">
|
||||
{replyTo?.quote ? `«${replyTo.quote}»` : (editingMessage || replyTo)?.content || t('media') || 'Медиа'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -739,14 +741,14 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-2 bg-surface-container-high rounded-[2rem] px-4 py-2 w-full max-w-4xl mx-auto shadow-sm transition-all duration-500 focus-within:shadow-xl focus-within:bg-surface-container-highest slide-on-ice">
|
||||
{/* Attach */}
|
||||
<div className="relative mb-0 flex-shrink-0 self-center">
|
||||
<div className="flex items-end gap-3 w-full max-w-4xl mx-auto px-4 pb-4">
|
||||
{/* Attach - Outside */}
|
||||
<div className="relative flex-shrink-0 self-end mb-1">
|
||||
<button
|
||||
onClick={() => setShowAttachMenu(!showAttachMenu)}
|
||||
className="w-10 h-10 rounded-full text-on-surface-variant hover:text-primary hover:bg-primary/10 transition-all slide-on-ice flex items-center justify-center"
|
||||
className="w-10 h-10 rounded-full text-on-surface-variant/60 hover:text-primary transition-all flex items-center justify-center p-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[24px]">add</span>
|
||||
<Paperclip size={24} strokeWidth={1.5} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showAttachMenu && (
|
||||
@@ -780,129 +782,73 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
|
||||
<input ref={imageInputRef} type="file" multiple accept="image/*,video/*" className="hidden" onChange={handleImageChange} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex-1 relative align-middle self-center">
|
||||
{/* @Mention popup */}
|
||||
<AnimatePresence>
|
||||
{mentionQuery !== null && filteredMembers.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl glass-strong shadow-2xl border border-white/10 py-1 z-50 max-h-48 overflow-y-auto"
|
||||
>
|
||||
{filteredMembers.map((m, i) => (
|
||||
<button
|
||||
key={m.user.id}
|
||||
onClick={() => insertMention(m)}
|
||||
className={`flex items-center gap-3 w-full px-3 py-2 text-left transition-colors ${
|
||||
i === mentionIndex ? 'bg-accent/20 text-white' : 'text-zinc-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{m.user.avatarUrl || m.user.avatar ? (
|
||||
<img src={m.user.avatarUrl || m.user.avatar || ''} className="w-7 h-7 rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
|
||||
{/* Main Input Pill */}
|
||||
<div className="flex-1 flex items-end gap-2 bg-[#2a2a2a] rounded-2xl px-3 py-1.5 transition-all duration-300 focus-within:bg-[#323232] shadow-sm relative min-h-[44px]">
|
||||
<button
|
||||
onClick={() => setShowEmoji(!showEmoji)}
|
||||
className="w-8 h-8 rounded-full text-on-surface-variant/50 hover:text-primary transition-all flex items-center justify-center flex-shrink-0 mb-0.5"
|
||||
>
|
||||
<Smile size={20} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 relative self-center">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setText(val);
|
||||
setDraft(chatId, val);
|
||||
emitTyping();
|
||||
if (isGroup) {
|
||||
const cursorPos = e.target.selectionStart;
|
||||
const match = val.substring(0, cursorPos).match(/@(\w*)$/);
|
||||
if (match) { setMentionQuery(match[1]); setMentionIndex(0); }
|
||||
else setMentionQuery(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleInputContextMenu}
|
||||
rows={1}
|
||||
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
|
||||
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{mentionQuery !== null && filteredMembers.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
className="absolute bottom-full left-0 right-0 mb-4 rounded-xl glass-strong shadow-2xl border border-white/10 py-1 z-50 max-h-48 overflow-y-auto"
|
||||
>
|
||||
{filteredMembers.map((m, i) => (
|
||||
<button
|
||||
key={m.user.id}
|
||||
onClick={() => insertMention(m)}
|
||||
className={`flex items-center gap-3 w-full px-3 py-2 text-left transition-colors ${
|
||||
i === mentionIndex ? 'bg-primary/20 text-white' : 'text-zinc-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[10px] font-bold">
|
||||
{(m.user.displayName || m.user.userName || m.user.username || '?')[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{m.user.displayName || m.user.userName || m.user.username || '??'}</p>
|
||||
<p className="text-xs text-zinc-500 truncate">@{m.user.userName || m.user.username || '??'}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setText(val);
|
||||
setDraft(chatId, val);
|
||||
emitTyping();
|
||||
// Detect @mention
|
||||
if (isGroup) {
|
||||
const cursorPos = e.target.selectionStart;
|
||||
const before = val.substring(0, cursorPos);
|
||||
const match = before.match(/@(\w*)$/);
|
||||
if (match) {
|
||||
setMentionQuery(match[1]);
|
||||
setMentionIndex(0);
|
||||
} else {
|
||||
setMentionQuery(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.clipboardData.files);
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
let tooLarge = false;
|
||||
let limitExceeded = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
tooLarge = true; continue;
|
||||
}
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
||||
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
||||
newAttachments.push({ file, type, preview });
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Файлы слишком большие');
|
||||
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleInputContextMenu}
|
||||
className="w-full bg-transparent border-none focus:ring-0 text-on-surface placeholder-on-surface-variant/30 text-[15px] leading-relaxed resize-none max-h-[150px] custom-scrollbar scroll-smooth outline-none py-1"
|
||||
placeholder={attachments.length > 0 ? t('addCaption') : t('message')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-1 self-center pb-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowEmoji(!showEmoji)}
|
||||
className="p-2 rounded-full text-zinc-400 hover:text-accent hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<Smile size={22} strokeWidth={1.5} />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{m.user.displayName || m.user.userName || m.user.username || '??'}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showEmoji && (
|
||||
<div className="absolute right-0 bottom-[calc(100%+12px)]">
|
||||
<div className="absolute left-0 bottom-[calc(100%+20px)]">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
setText((prev) => {
|
||||
@@ -919,16 +865,8 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
chatId,
|
||||
content: null,
|
||||
type: 'image',
|
||||
attachments: [{
|
||||
type: 'image',
|
||||
url: gifUrl,
|
||||
fileName: 'gif.gif',
|
||||
fileSize: 0,
|
||||
}],
|
||||
replyToId: replyTo?.id || null,
|
||||
quote: replyTo?.quote || null,
|
||||
attachments: [{ type: 'image', url: gifUrl, fileName: 'gif.gif', fileSize: 0 }],
|
||||
});
|
||||
setReplyTo(null);
|
||||
}
|
||||
setShowEmoji(false);
|
||||
}}
|
||||
@@ -939,21 +877,17 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Send / Mic */}
|
||||
<div className="flex-shrink-0 self-center relative flex items-center">
|
||||
{/* Send / Mic - Circular button outside pill */}
|
||||
<div className="flex-shrink-0 relative">
|
||||
{hasContent ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSend()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setScheduleStep('presets');
|
||||
setShowSchedule(true);
|
||||
}}
|
||||
onContextMenu={(e) => { e.preventDefault(); setScheduleStep('presets'); setShowSchedule(true); }}
|
||||
disabled={isSending}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-accent hover:bg-accent-hover transition-colors text-white disabled:opacity-50 shadow-md transform hover:scale-105"
|
||||
className="w-11 h-11 flex items-center justify-center rounded-2xl bg-primary text-white hover:brightness-110 active:scale-95 transition-all shadow-[0_4px_15px_rgba(48,150,229,0.3)] disabled:opacity-50"
|
||||
>
|
||||
<Send size={16} className="translate-x-[1px] translate-y-[1px]" />
|
||||
<Send size={20} strokeWidth={2.5} className="translate-x-[1px]" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showSchedule && (
|
||||
@@ -965,7 +899,6 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
exit={{ opacity: 0, scale: 0.9, y: 10 }}
|
||||
className="absolute bottom-[calc(100%+12px)] right-0 w-72 rounded-2xl glass-strong shadow-2xl z-50 border border-white/10 backdrop-blur-3xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
|
||||
{scheduleStep === 'custom' && (
|
||||
<button onClick={() => setScheduleStep('presets')} className="p-1 rounded-lg hover:bg-white/10 text-zinc-400 hover:text-white transition-colors">
|
||||
@@ -978,7 +911,6 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
|
||||
{scheduleStep === 'presets' ? (
|
||||
<div className="p-2 space-y-1">
|
||||
{/* Preset: 1 hour */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date(Date.now() + 3600000);
|
||||
@@ -991,7 +923,6 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
<Clock size={15} className="text-zinc-400 flex-shrink-0" />
|
||||
{t('scheduleIn1h')}
|
||||
</button>
|
||||
{/* Preset: 3 hours */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date(Date.now() + 3 * 3600000);
|
||||
@@ -1004,12 +935,9 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
<Clock size={15} className="text-zinc-400 flex-shrink-0" />
|
||||
{t('scheduleIn3h')}
|
||||
</button>
|
||||
{/* Preset: Tomorrow 9:00 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0);
|
||||
handleSend(d.toISOString());
|
||||
setShowSchedule(false);
|
||||
setScheduleToast(d.toLocaleString());
|
||||
@@ -1020,7 +948,6 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
{t('scheduleTomorrow')}
|
||||
</button>
|
||||
<div className="border-t border-white/5 my-1" />
|
||||
{/* Custom */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
@@ -1041,21 +968,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</div>
|
||||
) : (
|
||||
<ScheduleCalendar
|
||||
calDate={scheduleCalDate}
|
||||
setCalDate={setScheduleCalDate}
|
||||
calMonth={scheduleCalMonth}
|
||||
setCalMonth={setScheduleCalMonth}
|
||||
calYear={scheduleCalYear}
|
||||
setCalYear={setScheduleCalYear}
|
||||
hour={scheduleHour}
|
||||
setHour={setScheduleHour}
|
||||
minute={scheduleMinute}
|
||||
setMinute={setScheduleMinute}
|
||||
onSend={(iso) => {
|
||||
handleSend(iso);
|
||||
setShowSchedule(false);
|
||||
setScheduleToast(new Date(iso).toLocaleString());
|
||||
}}
|
||||
calDate={scheduleCalDate} setCalDate={setScheduleCalDate}
|
||||
calMonth={scheduleCalMonth} setCalMonth={setScheduleCalMonth}
|
||||
calYear={scheduleCalYear} setCalYear={setScheduleCalYear}
|
||||
hour={scheduleHour} setHour={setScheduleHour}
|
||||
minute={scheduleMinute} setMinute={setScheduleMinute}
|
||||
onSend={(iso) => { handleSend(iso); setShowSchedule(false); setScheduleToast(new Date(iso).toLocaleString()); }}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -1067,7 +985,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
) : (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full text-zinc-400 hover:text-accent hover:bg-surface-hover transition-colors"
|
||||
className="w-11 h-11 flex items-center justify-center rounded-2xl bg-primary text-white hover:brightness-110 active:scale-95 transition-all shadow-[0_4px_15px_rgba(48,150,229,0.3)]"
|
||||
>
|
||||
<Mic size={22} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user