diff --git a/.gitignore b/.gitignore
index 1821c7e..984ec54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ MatrixRoomUtils.Bot/bot_data/
appsettings.Local*.json
nixpkgs/
*.DotSettings.user
+*.patch
test.tsv
test-proxy.tsv
diff --git a/LibMatrix b/LibMatrix
-Subproject 37b97d65c0a5262539a5de560e911048166b8bb
+Subproject 896ee7f099f817e8cc9aba96a9db00fcce67163
diff --git a/MatrixUtils.Abstractions/RoomInfo.cs b/MatrixUtils.Abstractions/RoomInfo.cs
index 5c258a4..aff0e25 100644
--- a/MatrixUtils.Abstractions/RoomInfo.cs
+++ b/MatrixUtils.Abstractions/RoomInfo.cs
@@ -27,6 +27,7 @@ public class RoomInfo : NotifyPropertyChanged {
public readonly GenericRoom Room;
public ObservableCollection<StateEventResponse?> StateEvents { get; private set; } = new();
+ public ObservableCollection<StateEventResponse?> Timeline { get; private set; } = new();
private static ConcurrentBag<AuthenticatedHomeserverGeneric> homeserversWithoutEventFormatSupport = new();
private static SvgIdenticonGenerator identiconGenerator = new();
diff --git a/MatrixUtils.Web/MatrixUtils.Web.csproj b/MatrixUtils.Web/MatrixUtils.Web.csproj
index 53c056a..c2732de 100644
--- a/MatrixUtils.Web/MatrixUtils.Web.csproj
+++ b/MatrixUtils.Web/MatrixUtils.Web.csproj
@@ -46,5 +46,9 @@
<ItemGroup>
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
</ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="Pages\Tools\Moderation\Draupnir\" />
+ </ItemGroup>
</Project>
diff --git a/MatrixUtils.Web/Pages/Client/ClientComponents/ClientRoomList.razor b/MatrixUtils.Web/Pages/Client/ClientComponents/ClientRoomList.razor
new file mode 100644
index 0000000..845f30d
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Client/ClientComponents/ClientRoomList.razor
@@ -0,0 +1,15 @@
+@using ClientContext = MatrixUtils.Web.Pages.Client.Index.ClientContext
+@* user header and room list *@
+@foreach (var room in Data.SyncWrapper.Rooms) {
+ <LinkButton OnClick="@(async () => Data.SelectedRoom = room)" Color="@(Data.SelectedRoom == room ? "#FF00FF" : "")">
+ @room.RoomName
+ </LinkButton>
+ <br/>
+}
+
+@code {
+
+ [Parameter]
+ public ClientContext Data { get; set; } = null!;
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Client/ClientComponents/ClientStatusList.razor b/MatrixUtils.Web/Pages/Client/ClientComponents/ClientStatusList.razor
new file mode 100644
index 0000000..1100c98
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Client/ClientComponents/ClientStatusList.razor
@@ -0,0 +1,35 @@
+@using ClientContext = MatrixUtils.Web.Pages.Client.Index.ClientContext;
+@using System.Collections.ObjectModel
+
+@foreach (var ctx in Data) {
+ <pre>
+ @ctx.Homeserver.UserId - @ctx.SyncWrapper.Status
+ </pre>
+}
+
+@code {
+
+ [Parameter]
+ public ObservableCollection<ClientContext> Data { get; set; } = null!;
+
+ protected override void OnInitialized() {
+ Data.CollectionChanged += (_, e) => {
+ foreach (var item in e.NewItems?.Cast<ClientContext>() ?? []) {
+ item.SyncWrapper.PropertyChanged += (_, pe) => {
+ if (pe.PropertyName == nameof(item.SyncWrapper.Status))
+ StateHasChanged();
+ };
+ }
+
+ StateHasChanged();
+ };
+
+ Data.ToList().ForEach(ctx => {
+ ctx.SyncWrapper.PropertyChanged += (_, pe) => {
+ if (pe.PropertyName == nameof(ctx.SyncWrapper.Status))
+ StateHasChanged();
+ };
+ });
+ }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Client/ClientComponents/ClientSyncWrapper.cs b/MatrixUtils.Web/Pages/Client/ClientComponents/ClientSyncWrapper.cs
new file mode 100644
index 0000000..16051b8
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Client/ClientComponents/ClientSyncWrapper.cs
@@ -0,0 +1,41 @@
+using System.Collections.ObjectModel;
+using ArcaneLibs;
+using LibMatrix;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using MatrixUtils.Abstractions;
+
+namespace MatrixUtils.Web.Pages.Client.ClientComponents;
+
+public class ClientSyncWrapper(AuthenticatedHomeserverGeneric homeserver) : NotifyPropertyChanged {
+ private SyncHelper _syncHelper = new SyncHelper(homeserver) {
+ MinimumDelay = TimeSpan.FromMilliseconds(2000),
+ IsInitialSync = false
+ };
+ private string _status = "Loading...";
+
+ public ObservableCollection<StateEvent> AccountData { get; set; } = new();
+ public ObservableCollection<RoomInfo> Rooms { get; set; } = new();
+
+ public string Status {
+ get => _status;
+ set => SetField(ref _status, value);
+ }
+
+ public async Task Start() {
+ Task.Yield();
+ var resp = _syncHelper.EnumerateSyncAsync();
+ Status = $"[{DateTime.Now:s}] Syncing...";
+ await foreach (var response in resp) {
+ Task.Yield();
+ Status = $"[{DateTime.Now:s}] {response.Rooms?.Join?.Count ?? 0 + response.Rooms?.Invite?.Count ?? 0 + response.Rooms?.Leave?.Count ?? 0} rooms, {response.AccountData?.Events?.Count ?? 0} account data, {response.ToDevice?.Events?.Count ?? 0} to-device, {response.DeviceLists?.Changed?.Count ?? 0} device lists, {response.Presence?.Events?.Count ?? 0} presence updates";
+ await HandleSyncResponse(response);
+ await Task.Yield();
+ }
+ }
+
+ private async Task HandleSyncResponse(SyncResponse resp) {
+
+ }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Client/ClientComponents/MatrixClient.razor b/MatrixUtils.Web/Pages/Client/ClientComponents/MatrixClient.razor
new file mode 100644
index 0000000..b4a81f7
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Client/ClientComponents/MatrixClient.razor
@@ -0,0 +1,31 @@
+@using Index = MatrixUtils.Web.Pages.Client.Index
+@using MatrixUtils.Web.Pages.Client.ClientComponents
+
+<div class="container-fluid">
+ <div class="row">
+ <div class="col-3">
+ <ClientRoomList Data="@Data"/>
+ </div>
+ <div class="col-6">
+ @if (Data.SelectedRoom != null) {
+ <Index.RoomHeader Data="@Data"/>
+ <Index.RoomTimeline Data="@Data"/>
+ }
+ else {
+ <p>No room selected</p>
+ }
+ </div>
+ @if (Data.SelectedRoom != null) {
+ <div class="col-3">
+ <Index.UserList Data="@Data"/>
+ </div>
+ }
+ </div>
+</div>
+
+@code {
+
+ [Parameter]
+ public Index.ClientContext Data { get; set; } = null!;
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Client/Index.razor b/MatrixUtils.Web/Pages/Client/Index.razor
new file mode 100644
index 0000000..2a9a327
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Client/Index.razor
@@ -0,0 +1,72 @@
+@page "/Client"
+@using LibMatrix
+@using MatrixUtils.Abstractions
+@using MatrixUtils.Web.Pages.Client.ClientComponents
+@using System.Collections.ObjectModel
+
+<h3>Client</h3>
+
+
+@foreach (var client in Clients) {
+ <LinkButton Color="@(SelectedClient == client ? "#ff00ff" : "")" OnClick="@(async () => SelectedClient = client)">
+ @client.Homeserver.WhoAmI.UserId
+ </LinkButton>
+}
+<ClientStatusList Data="@Clients"></ClientStatusList>
+
+
+@* @foreach (var client in Clients) { *@
+@* <div class="card"> *@
+@* <span>@client.Homeserver.UserId - @client.SyncWrapper.Status</span> *@
+@* </div> *@
+@* } *@
+
+@if (SelectedClient != null) {
+ <div class="card">
+ <MatrixClient Data="@SelectedClient"/>
+ </div>
+}
+
+@code {
+
+ private static readonly ObservableCollection<ClientContext> Clients = [];
+ private static ClientContext _selectedClient;
+
+ private ClientContext SelectedClient {
+ get => _selectedClient;
+ set {
+ _selectedClient = value;
+ StateHasChanged();
+ }
+ }
+
+ protected override async Task OnInitializedAsync() {
+ var tokens = await RMUStorage.GetAllTokens();
+ var tasks = tokens.Select(async token => {
+ try {
+ var cc = new ClientContext() {
+ Homeserver = await RMUStorage.GetSession(token)
+ };
+ cc.SyncWrapper = new ClientSyncWrapper(cc.Homeserver);
+
+#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
+ cc.SyncWrapper.Start();
+#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
+
+ Clients.Add(cc);
+ StateHasChanged();
+ }
+ catch { }
+ }).ToList();
+ await Task.WhenAll(tasks);
+ }
+
+ public class ClientContext {
+ public AuthenticatedHomeserverGeneric Homeserver { get; set; }
+ public ClientSyncWrapper SyncWrapper { get; set; }
+
+ public RoomInfo? SelectedRoom { get; set; }
+ }
+
+}
+
diff --git a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor
index afd58af..11df261 100644
--- a/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor
+++ b/MatrixUtils.Web/Pages/HSAdmin/RoomQuery.razor
@@ -82,18 +82,19 @@
@foreach (var res in Results) {
<div style="background-color: #ffffff11; border-radius: 0.5em; display: block; margin-top: 4px; padding: 4px;">
- <RoomListItem RoomName="@res.Name" RoomId="@res.RoomId"></RoomListItem>
+ @* <RoomListItem RoomName="@res.Name" RoomId="@res.RoomId"></RoomListItem> *@
<p>
@if (!string.IsNullOrWhiteSpace(res.CanonicalAlias)) {
- <span>@res.CanonicalAlias (@res.RoomId)</span>
+ <span>@res.CanonicalAlias - @res.RoomId (@res.Name)</span>
<br/>
}
else {
- <span>@res.RoomId</span>
+ <span>@res.RoomId (@res.Name)</span>
<br/>
}
@if (!string.IsNullOrWhiteSpace(res.Creator)) {
- <span>Created by <InlineUserItem UserId="@res.Creator"></InlineUserItem></span>
+ @* <span>Created by <InlineUserItem UserId="@res.Creator"></InlineUserItem></span> *@
+ <span>Created by @res.Creator</span>
<br/>
}
</p>
@@ -178,6 +179,7 @@
}
}
+ StateHasChanged();
}
private readonly Dictionary<string, string> validOrderBy = new() {
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor b/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor
index 7a3b27b..6483f01 100644
--- a/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor
@@ -1,15 +1,16 @@
@using MatrixUtils.Abstractions
<div class="spaceListItem" style="@(SelectedSpace == Space ? "background-color: #FFFFFF33;" : "")" onclick="@SelectSpace">
- @if (IsSpaceOpened()) {
- <span onclick="@ToggleSpace">▼ </span>
- }
- else {
- <span onclick="@ToggleSpace">▶ </span>
- }
-
- <MxcImage Circular="true" Height="32" Width="32" Homeserver="Space.Room.Homeserver" MxcUri="@Space.RoomIcon"></MxcImage>
- <span class="spaceNameEllipsis">@Space.RoomName</span>
+ <div class="spaceListItemContainer">
+ @if (IsSpaceOpened()) {
+ <span onclick="@ToggleSpace">▼ </span>
+ }
+ else {
+ <span onclick="@ToggleSpace">▶ </span>
+ }
+ <MxcImage Circular="true" Height="32" Width="32" Homeserver="Space.Room.Homeserver" MxcUri="@Space.RoomIcon"></MxcImage>
+ <span class="spaceNameEllipsis">@Space.RoomName</span>
+ </div>
@if (IsSpaceOpened()) {
<span>meow</span>
}
@@ -19,10 +20,10 @@
[Parameter]
public RoomInfo Space { get; set; }
-
+
[Parameter]
public RoomInfo SelectedSpace { get; set; }
-
+
[Parameter]
public EventCallback<RoomInfo> SelectedSpaceChanged { get; set; }
@@ -52,5 +53,4 @@
return OpenedSpaces.Contains(Space);
}
-}
-
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css b/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css
index a88975b..d6e413f 100644
--- a/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css
+++ b/MatrixUtils.Web/Pages/Rooms/Index2Components/MainTabComponents/MainTabSpaceItem.razor.css
@@ -11,7 +11,17 @@
.spaceListItem {
display: block;
width: 100%;
- height: 50px;
+ height: 3em;
+}
+
+.spaceListItemContainer {
+ display: flex;
+ align-items: center;
+ vertical-align: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ width: 100%;
+ height: 100%;
}
.spaceListItem > img {
diff --git a/MatrixUtils.Web/Pages/Tools/UserTrace.razor b/MatrixUtils.Web/Pages/Tools/UserTrace.razor
index 4ad9874..95fe02b 100644
--- a/MatrixUtils.Web/Pages/Tools/UserTrace.razor
+++ b/MatrixUtils.Web/Pages/Tools/UserTrace.razor
@@ -5,6 +5,7 @@
@using LibMatrix
@using System.Collections.Frozen
@using LibMatrix.EventTypes.Spec.State
+@using LibMatrix.Filters
@using MatrixUtils.Abstractions
<h3>User Trace</h3>
<hr/>
@@ -17,7 +18,7 @@
<details>
<summary>Rooms to be searched (@rooms.Count)</summary>
@foreach (var room in rooms) {
- <span>@room.Room.RoomId</span>
+ <span>@room.RoomId</span>
<br/>
}
</details>
@@ -48,8 +49,11 @@
}
@code {
+
private ObservableCollection<string> log { get; set; } = new();
- List<RoomInfo> rooms { get; set; } = new();
+
+ // List<RoomInfo> rooms { get; set; } = new();
+ List<GenericRoom> rooms { get; set; } = [];
Dictionary<string, List<Match>> matches = new();
private string UserIdString {
@@ -63,46 +67,58 @@
log.CollectionChanged += (sender, args) => StateHasChanged();
var hs = await RMUStorage.GetCurrentSessionOrNavigate();
if (hs is null) return;
- var sessions = await RMUStorage.GetAllTokens();
- var baseRooms = new List<GenericRoom>();
- foreach (var userAuth in sessions) {
- var session = await RMUStorage.GetSession(userAuth);
- if (session is not null) {
- baseRooms.AddRange(await session.GetJoinedRooms());
- var sessionRooms = (await session.GetJoinedRooms()).Where(x => !rooms.Any(y => y.Room.RoomId == x.RoomId)).ToList();
- StateHasChanged();
- log.Add($"Got {sessionRooms.Count} rooms for {userAuth.UserId}");
+ // var sessions = await RMUStorage.GetAllTokens();
+ // var baseRooms = new List<GenericRoom>();
+ // foreach (var userAuth in sessions) {
+ // var session = await RMUStorage.GetSession(userAuth);
+ // if (session is not null) {
+ // baseRooms.AddRange(await session.GetJoinedRooms());
+ // var sessionRooms = (await session.GetJoinedRooms()).Where(x => !rooms.Any(y => y.Room.RoomId == x.RoomId)).ToList();
+ // StateHasChanged();
+ // log.Add($"Got {sessionRooms.Count} rooms for {userAuth.UserId}");
+ // }
+ // }
+ //
+ // log.Add("Done fetching rooms!");
+ //
+ // baseRooms = baseRooms.DistinctBy(x => x.RoomId).ToList();
+ //
+ // // rooms.CollectionChanged += (sender, args) => StateHasChanged();
+ // var tasks = baseRooms.Select(async newRoom => {
+ // bool success = false;
+ // while (!success)
+ // try {
+ // var state = await newRoom.GetFullStateAsListAsync();
+ // var newRoomInfo = new RoomInfo(newRoom, state);
+ // rooms.Add(newRoomInfo);
+ // log.Add($"Got {newRoomInfo.StateEvents.Count} events for {newRoomInfo.RoomName}");
+ // success = true;
+ // }
+ // catch (MatrixException e) {
+ // log.Add($"Failed to fetch room {newRoom.RoomId}! {e}");
+ // throw;
+ // }
+ // catch (HttpRequestException e) {
+ // log.Add($"Failed to fetch room {newRoom.RoomId}! {e}");
+ // }
+ // });
+ // await Task.WhenAll(tasks);
+ //
+ // log.Add($"Done fetching members!");
+ //
+ // UserIDs.RemoveAll(x => sessions.Any(y => y.UserId == x));
+
+ foreach (var session in await RMUStorage.GetAllTokens()) {
+ var _hs = await RMUStorage.GetSession(session);
+ if (_hs is not null) {
+ rooms.AddRange(await _hs.GetJoinedRooms());
+ log.Add($"Got {rooms.Count} rooms after adding {_hs.UserId}");
}
}
- log.Add("Done fetching rooms!");
-
- baseRooms = baseRooms.DistinctBy(x => x.RoomId).ToList();
-
- // rooms.CollectionChanged += (sender, args) => StateHasChanged();
- var tasks = baseRooms.Select(async newRoom => {
- bool success = false;
- while (!success)
- try {
- var state = await newRoom.GetFullStateAsListAsync();
- var newRoomInfo = new RoomInfo(newRoom, state);
- rooms.Add(newRoomInfo);
- log.Add($"Got {newRoomInfo.StateEvents.Count} events for {newRoomInfo.RoomName}");
- success = true;
- }
- catch (MatrixException e) {
- log.Add($"Failed to fetch room {newRoom.RoomId}! {e}");
- throw;
- }
- catch (HttpRequestException e) {
- log.Add($"Failed to fetch room {newRoom.RoomId}! {e}");
- }
- });
- await Task.WhenAll(tasks);
-
- log.Add($"Done fetching members!");
-
- UserIDs.RemoveAll(x => sessions.Any(y => y.UserId == x));
+ //get distinct rooms evenly distributed per session, accounting for count per session
+ rooms = rooms.OrderBy(x => rooms.Count(y => y.Homeserver == x.Homeserver)).DistinctBy(x => x.RoomId).ToList();
+ log.Add($"Got {rooms.Count} rooms");
StateHasChanged();
Console.WriteLine("Rerendered!");
@@ -113,18 +129,25 @@
foreach (var userId in UserIDs) {
matches.Add(userId, new List<Match>());
- foreach (var room in rooms) {
- var state = room.StateEvents.Where(x => x!.Type == RoomMemberEventContent.EventId).ToList();
- if (state!.Any(x => x.StateKey == userId)) {
- matches[userId].Add(new() {
- Event = state.First(x => x.StateKey == userId),
- Room = room.Room,
- RoomName = room.RoomName ?? "No name"
- });
- }
+ // foreach (var room in rooms) {
+ // var state = room.StateEvents.Where(x => x!.Type == RoomMemberEventContent.EventId).ToList();
+ // if (state!.Any(x => x.StateKey == userId)) {
+ // matches[userId].Add(new() {
+ // Event = state.First(x => x.StateKey == userId),
+ // Room = room.Room,
+ // RoomName = room.RoomName ?? "No name"
+ // });
+ // }
+ // }
+
+ log.Add($"Searching for {userId}...");
+ await foreach (var match in GetMatches(userId)) {
+ matches[userId].Add(match);
}
}
+ log.Add("Done!");
+
StateHasChanged();
return "";
@@ -135,8 +158,8 @@
private async Task DoImportFromRoomId() {
try {
if (ImportFromRoomId is null) return;
- var room = rooms.FirstOrDefault(x => x.Room.RoomId == ImportFromRoomId);
- UserIdString = string.Join("\n", (await room.Room.GetMembersListAsync()).Select(x => x.StateKey));
+ var room = rooms.FirstOrDefault(x => x.RoomId == ImportFromRoomId);
+ UserIdString = string.Join("\n", (await room.GetMembersListAsync()).Select(x => x.StateKey));
}
catch (Exception e) {
Console.WriteLine(e);
@@ -152,4 +175,24 @@
public string RoomName { get; set; }
}
+ private async IAsyncEnumerable<Match> GetMatches(string userId) {
+ var results = rooms.Select(async room => {
+ var state = await room.GetStateEventOrNullAsync(room.RoomId, userId);
+ if (state is not null) {
+ return new Match {
+ Room = room,
+ Event = state,
+ RoomName = await room.GetNameOrFallbackAsync()
+ };
+ }
+
+ return null;
+ }).ToAsyncEnumerable();
+ await foreach (var result in results) {
+ if (result is not null) {
+ yield return result;
+ }
+ }
+ }
+
}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor b/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor
deleted file mode 100644
index 5197a6f..0000000
--- a/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor
+++ /dev/null
@@ -1,38 +0,0 @@
-@* Source: https://whuysentruit.medium.com/blazor-wasm-pwa-adding-a-new-update-available-notification-d9f65c4ad13 *@
-@inject IJSRuntime _jsRuntime
-
-@if (_newVersionAvailable)
-{
- <button type="button" class="btn btn-warning shadow floating-update-button" onclick="window.location.reload()">
- A new version of the application is available. Click here to reload.
- </button>
-}
-
-@code {
-
- private bool _newVersionAvailable = false;
-
- protected override async Task OnInitializedAsync()
- {
- await RegisterForUpdateAvailableNotification();
- }
-
- private async Task RegisterForUpdateAvailableNotification()
- {
- await _jsRuntime.InvokeAsync<object>(
- identifier: "registerForUpdateAvailableNotification",
- DotNetObjectReference.Create(this),
- nameof(OnUpdateAvailable));
- }
-
- [JSInvokable(nameof(OnUpdateAvailable))]
- public Task OnUpdateAvailable()
- {
- _newVersionAvailable = true;
-
- StateHasChanged();
-
- return Task.CompletedTask;
- }
-
-}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor.css b/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor.css
deleted file mode 100644
index 32bff09..0000000
--- a/MatrixUtils.Web/Shared/UpdateAvailableDetector.razor.css
+++ /dev/null
@@ -1,15 +0,0 @@
-.floating-update-button {
- position: fixed;
-
- right: 2rem;
- bottom: 2rem;
-
- padding: 1rem 1.5rem;
-
- animation: fadein 2s ease-out;
-}
-
-@keyframes fadein {
- from { right: -100%; }
- to { right: 2rem; }
-}
\ No newline at end of file
|