diff --git a/Blacklight/Blacklight.csproj b/Blacklight/Blacklight.csproj
index 44b879eb95e16045143269a8a1797023956b8507..1901658e5cb353a73d3a091eb5eb0bf92393ee3f 100644
--- a/Blacklight/Blacklight.csproj
+++ b/Blacklight/Blacklight.csproj
@@ -19,12 +19,14 @@
         <PackageReference Include="Avalonia.Fonts.Inter" Version="11.1.3" />
         <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
         <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.1.3" />
+        <PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.1.0.7" />
         <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
         <PackageReference Include="Dock.Avalonia" Version="11.1.0.1" />
         <PackageReference Include="Dock.Model.Mvvm" Version="11.1.0.1" />
         <PackageReference Include="Markdig" Version="0.37.0" />
         <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
         <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
+        <PackageReference Include="ReactiveUI" Version="20.1.63" />
         <PackageReference Include="Serilog" Version="4.0.1" />
         <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
         <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
diff --git a/Blacklight/TODO b/Blacklight/TODO
new file mode 100644
index 0000000000000000000000000000000000000000..162e95a9bc7580f011fdcdbb7f32dc9617792d0c
--- /dev/null
+++ b/Blacklight/TODO
@@ -0,0 +1,21 @@
+next things to do:
+
+[done] Scrolling in chat
+
+[done] Load chat history
+
+[done] Timestamps
+
+[done] Timestamps 2: Sorting boogaloo
+
+[done] Message edit and delete events
+
+Scroll to top for more messages
+
+Message edited indicator
+
+Message editing and deleting (scary for editing?)
+
+Maybe look into transforming fakeMsg into final message instead of remove and then wait for event to arrive,
+or remove it when the event has arrived (how to tell? -> Client attribute with guid?)
+
diff --git a/Blacklight/Util/ScrollToEndBehavior.cs b/Blacklight/Util/ScrollToEndBehavior.cs
new file mode 100644
index 0000000000000000000000000000000000000000..4b6dffae17742045a961621a302ffe77fae25d42
--- /dev/null
+++ b/Blacklight/Util/ScrollToEndBehavior.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Threading;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using Avalonia.Xaml.Interactivity;
+using Serilog;
+
+namespace Blacklight.Util;
+
+public class ScrollToEndBehavior : Behavior<ScrollViewer>
+{
+    public static readonly StyledProperty<bool> ShouldScrollToEndProperty =
+        AvaloniaProperty.Register<ScrollToEndBehavior, bool>(nameof(ShouldScrollToEnd));
+
+    public bool ShouldScrollToEnd
+    {
+        get => GetValue(ShouldScrollToEndProperty);
+        set => SetValue(ShouldScrollToEndProperty, value);
+    }
+
+    protected override void OnAttached()
+    {
+        base.OnAttached();
+        // Optionally listen for property changes
+        this.GetObservable(ShouldScrollToEndProperty).Subscribe(ScrollNow);
+    }
+
+    private void ScrollNow(bool shouldScroll)
+    {
+        Log.Information("I should scroll maybe? {ShouldScroll}, {Other}", shouldScroll, AssociatedObject != null);
+        if (shouldScroll && AssociatedObject != null)
+        {
+            Dispatcher.UIThread.Post(() =>
+            {
+                AssociatedObject.ScrollToEnd();
+            });
+        }
+    }
+}
diff --git a/Blacklight/ViewModels/DockFactory.cs b/Blacklight/ViewModels/DockFactory.cs
index 3026c107a3ba8738d0eca985fabba254002c9c7f..414f3124cfbe01608e75482eb5f31d93e8115fdd 100644
--- a/Blacklight/ViewModels/DockFactory.cs
+++ b/Blacklight/ViewModels/DockFactory.cs
@@ -200,10 +200,9 @@ public class DockContext
     
     public void AddDocument(Channel channel, bool isEphemeral, bool shouldCloseEphemeral = true)
     {
-        var document = new DocumentViewModel
+        var document = new DocumentViewModel(channel)
         {
             Id = channel.Id.ToString(),
-            Channel = channel,
             Title = channel.Name,
             CanPin = false,
             CanFloat = false
diff --git a/Blacklight/ViewModels/Documents/DocumentViewModel.cs b/Blacklight/ViewModels/Documents/DocumentViewModel.cs
index d43ac06eeb8fac54ab1311dc9a8503d639313bfe..6a86f6ab02b3a1f28b6096ecf0ab52b4867dd675 100644
--- a/Blacklight/ViewModels/Documents/DocumentViewModel.cs
+++ b/Blacklight/ViewModels/Documents/DocumentViewModel.cs
@@ -1,10 +1,14 @@
 using System;
-using System.Collections.Generic;
 using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Threading.Tasks;
 using System.Windows.Input;
 using Avalonia.Media;
+using Avalonia.Threading;
 using CommunityToolkit.Mvvm.Input;
 using Dock.Model.Mvvm.Controls;
+using DynamicData;
+using DynamicData.Binding;
 using Lightquark.NET;
 using Lightquark.NET.Objects;
 using Microsoft.Extensions.DependencyInjection;
@@ -16,9 +20,48 @@ public class DocumentViewModel : Document
 {
     private bool _isEphemeral;
     private string _messageInput = "";
-    public required Channel Channel { get; set; }
+    private bool _scrollToEndTrigger;
+    private bool _scrollLocked = true;
+    public Channel Channel { get; set; }
+    private ReadOnlyObservableCollection<Message> _messages;
 
-    public ObservableCollection<Message> Messages
+    public bool ScrollToEndTrigger
+    {
+        get => _scrollToEndTrigger;
+        set => SetProperty(ref _scrollToEndTrigger, value);
+    }
+
+    public DocumentViewModel(Channel channel)
+    {
+        Channel = channel;
+        MessagesCollection
+            .ToObservableChangeSet()
+            .Sort(SortExpressionComparer<Message>.Ascending(m => m.Timestamp))
+            .Bind(out _messages)
+            .Subscribe();
+        ((INotifyCollectionChanged)Messages).CollectionChanged += (_, _) =>
+        {
+            if (!_scrollLocked) return;
+            Dispatcher.UIThread.Post(() =>
+            {
+                Log.Information("New message! Scroll status {Status}", ScrollToEndTrigger);
+                ScrollToEndTrigger = false;
+                ScrollToEndTrigger = true;
+            });
+        };
+    }
+
+    public void ScrollChanged(double deltaY, double currentOffset, double maxOffset)
+    {
+        if (deltaY < 0) _scrollLocked = false;
+        // If roughly at the bottom lock the scroll again
+        if (Math.Abs(currentOffset - maxOffset) < 10) _scrollLocked = true;
+        // Log.Information("Scroll changed {Delta} => {Current}/{Max}", deltaY, currentOffset, maxOffset);
+    }
+    
+    public ReadOnlyObservableCollection<Message> Messages => _messages;
+    
+    private ObservableCollection<Message> MessagesCollection
     {
         get
         {
@@ -28,6 +71,24 @@ public class DocumentViewModel : Document
                 Log.Information("Channel {ChannelId} has no messages, creating collection", Channel.Id);
                 messageCollection = new ObservableCollection<Message>();
                 client.Messages[Channel.Id] = messageCollection;
+                Task.Run(async () =>
+                {
+                    try
+                    {
+                        var messages = await client.GetMessages(Channel.Id);
+                        Dispatcher.UIThread.Post(() =>
+                        {
+                            foreach (var message in messages)
+                            {
+                                messageCollection.Add(message);
+                            }
+                        });
+                    }
+                    catch (Exception)
+                    {
+                        // Unfortunate
+                    }
+                });
             }
             return messageCollection;
         }
diff --git a/Blacklight/Views/Documents/DocumentView.axaml b/Blacklight/Views/Documents/DocumentView.axaml
index 6f3a5b98fe87e5ab464eb89c890508513b07c9ec..4de1e4ba7e55803f9e74b51e833978cdf3de950e 100644
--- a/Blacklight/Views/Documents/DocumentView.axaml
+++ b/Blacklight/Views/Documents/DocumentView.axaml
@@ -1,55 +1,66 @@
 <UserControl x:Class="Blacklight.Views.Documents.DocumentView"
-              xmlns="https://github.com/avaloniaui"
-              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-              xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
-              xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
-              xmlns:vm="using:Blacklight.ViewModels.Documents"
-              xmlns:util="clr-namespace:Blacklight.Util"
-              xmlns:objects="clr-namespace:Lightquark.NET.Objects;assembly=Lightquark.NET"
-              xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Collections"
-              mc:Ignorable="d"
-              d:DesignWidth="300" d:DesignHeight="400"
-              x:DataType="vm:DocumentViewModel" x:CompileBindings="True">
-    <!-- <Grid Focusable="True" Background="{DynamicResource DocumentColor}"> -->
-        <Grid VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Background="{DynamicResource DocumentColor}">
-            <Grid.RowDefinitions>
-                <RowDefinition Height="*"/>
-                <RowDefinition Height="Auto"/>
-            </Grid.RowDefinitions>
-            <StackPanel Grid.Row="0">
-                <!-- <TextBlock Text="{Binding Id}" Padding="2" /> -->
-                <!-- <TextBlock Text="{Binding Title}" Padding="2" /> -->
-                <!-- <TextBlock Text="{Binding Context}" Padding="2" /> -->
-                <!-- <TextBlock Text="{Binding IsEphemeral}" Padding="2" /> -->
-                <!-- <Button Command="{Binding TestCommand}">Meow</Button> -->
-                <ItemsControl ItemsSource="{Binding Messages}" >
-                    <ItemsControl.ItemTemplate>
-                        <DataTemplate>
-                            <StackPanel Margin="8 3" Orientation="Vertical">
-                                <StackPanel Orientation="Horizontal">
-                                    <TextBlock VerticalAlignment="Center" FontWeight="Bold" Text="{Binding VisualAuthor.Username}" />
-                                    <Border Background="DeepPink" Padding="2" CornerRadius="3" Margin="5 0" IsVisible="{Binding VisualAuthor.IsBot}">
-                                        <TextBlock FontWeight="Bold" Text="{Binding VisualAuthor.BotTag}" />
-                                    </Border>
-                                </StackPanel>
-                                <TextBlock Foreground="{Binding Visuals.TextColor, Converter={StaticResource MessageStatusEnumToColor}}" Text="{Binding Content}" />
-                            </StackPanel>
-                        </DataTemplate>
-                    </ItemsControl.ItemTemplate>
-                </ItemsControl>
-            </StackPanel>
-            <Grid Grid.Row="1" HorizontalAlignment="Stretch">
-                <Grid.ColumnDefinitions>
-                    <ColumnDefinition Width="*" />
-                    <ColumnDefinition Width="Auto" />
-                </Grid.ColumnDefinitions>
-                <TextBox MaxHeight="200" Grid.Column="0" AcceptsReturn="True" TextWrapping="Wrap" Text="{Binding MessageInput}">
-                    <TextBox.KeyBindings>
-                        <KeyBinding Gesture="Enter" Command="{Binding SendCommand}"/>
-                    </TextBox.KeyBindings>
-                </TextBox>
-                <Button Grid.Column="1" Command="{Binding SendCommand}">Send</Button>
-            </Grid>
-        </Grid>
-    <!-- </Grid> -->
+             xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:vm="using:Blacklight.ViewModels.Documents"
+             xmlns:util="clr-namespace:Blacklight.Util"
+             xmlns:objects="clr-namespace:Lightquark.NET.Objects;assembly=Lightquark.NET"
+             xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Collections"
+             mc:Ignorable="d"
+             d:DesignWidth="300" d:DesignHeight="400"
+             x:DataType="vm:DocumentViewModel" x:CompileBindings="True">
+	<!-- <Grid Focusable="True" Background="{DynamicResource DocumentColor}"> -->
+	<Grid VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Background="{DynamicResource DocumentColor}">
+		<Grid.RowDefinitions>
+			<RowDefinition Height="*" />
+			<RowDefinition Height="Auto" />
+		</Grid.RowDefinitions>
+		<!-- <TextBlock Text="{Binding Id}" Padding="2" /> -->
+		<!-- <TextBlock Text="{Binding Title}" Padding="2" /> -->
+		<!-- <TextBlock Text="{Binding Context}" Padding="2" /> -->
+		<!-- <TextBlock Text="{Binding IsEphemeral}" Padding="2" /> -->
+		<!-- <Button Command="{Binding TestCommand}">Meow</Button> -->
+		<ScrollViewer Grid.Row="0" Name="MessageView" ScrollChanged="MessageView_OnScrollChanged">
+			<Interaction.Behaviors>
+				<util:ScrollToEndBehavior ShouldScrollToEnd="{Binding ScrollToEndTrigger}" />
+			</Interaction.Behaviors>
+			<ItemsControl ItemsSource="{Binding Messages}" x:Name="MessageItems">
+				<ItemsControl.ItemTemplate>
+					<DataTemplate>
+						<StackPanel Margin="8 3" Orientation="Vertical">
+							<StackPanel Orientation="Horizontal">
+								<TextBlock VerticalAlignment="Center" FontWeight="Bold" Text="{Binding VisualAuthor.Username}" />
+								<Border Background="DeepPink" Padding="2" CornerRadius="3" Margin="5 0"
+								        IsVisible="{Binding VisualAuthor.IsBot}">
+									<TextBlock FontWeight="Bold" Text="{Binding VisualAuthor.BotTag}" />
+								</Border>
+								<TextBlock Foreground="Gray" Text="{Binding PrettyTime}">
+									<ToolTip.Tip>
+										<TextBlock Text="{Binding PrettyTimePrecise}" />
+									</ToolTip.Tip>
+								</TextBlock>
+							</StackPanel>
+							<TextBlock Foreground="{Binding Visuals.TextColor, Converter={StaticResource MessageStatusEnumToColor}}"
+							           TextWrapping="Wrap"
+							           Text="{Binding Content}" />
+						</StackPanel>
+					</DataTemplate>
+				</ItemsControl.ItemTemplate>
+			</ItemsControl>
+		</ScrollViewer>
+		<Grid Grid.Row="1" HorizontalAlignment="Stretch">
+			<Grid.ColumnDefinitions>
+				<ColumnDefinition Width="*" />
+				<ColumnDefinition Width="Auto" />
+			</Grid.ColumnDefinitions>
+			<TextBox MaxHeight="200" Grid.Column="0" AcceptsReturn="True" TextWrapping="Wrap" Text="{Binding MessageInput}">
+				<TextBox.KeyBindings>
+					<KeyBinding Gesture="Enter" Command="{Binding SendCommand}" />
+				</TextBox.KeyBindings>
+			</TextBox>
+			<Button Grid.Column="1" Command="{Binding SendCommand}">Send</Button>
+		</Grid>
+	</Grid>
+	<!-- </Grid> -->
 </UserControl>
\ No newline at end of file
diff --git a/Blacklight/Views/Documents/DocumentView.axaml.cs b/Blacklight/Views/Documents/DocumentView.axaml.cs
index 9eda30951ebda2caa797acd1bcd985983fa03b75..3f268f81fb3fabbfc50d55cb93048f324bf83521 100644
--- a/Blacklight/Views/Documents/DocumentView.axaml.cs
+++ b/Blacklight/Views/Documents/DocumentView.axaml.cs
@@ -1,6 +1,9 @@
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using Blacklight.ViewModels.Documents;
+using Serilog;
 
 namespace Blacklight.Views.Documents;
 
@@ -10,9 +13,15 @@ public partial class DocumentView : UserControl
     {
         InitializeComponent();
     }
-    
+
     private void InitializeComponent()
     {
         AvaloniaXamlLoader.Load(this);
     }
+
+    private void MessageView_OnScrollChanged(object? sender, ScrollChangedEventArgs e)
+    {
+        var sv = (ScrollViewer)sender!;
+        ((DocumentViewModel)DataContext!).ScrollChanged(e.OffsetDelta.Y, sv.Offset.Y, sv.ScrollBarMaximum.Y);
+    }
 }
\ No newline at end of file
diff --git a/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs b/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs
index 2cbaf966f508feeb002cba5856b11f26334f4917..9e657ea8e0a1ee8a8b82789b0e80cd05bc0009a6 100644
--- a/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs
+++ b/Blacklight/Views/Tools/ResourceExplorerView.axaml.cs
@@ -36,7 +36,6 @@ public partial class ResourceExplorerView : UserControl
                         _doubleTimer?.Dispose();
                         _selectedItem = null;
                         App.DockContext.AddDocument(quarkListItem.Channel!, false);
-                        // TODO: Open persistent tab
                     }
                     else
                     {
@@ -53,7 +52,6 @@ public partial class ResourceExplorerView : UserControl
                         };
                         _doubleTimer.Start();
 
-                        // TODO: Open or update ephemeral tab
                         App.DockContext.AddDocument(quarkListItem.Channel!, true);
                     }
                     break;
diff --git a/Lightquark.NET/Client.cs b/Lightquark.NET/Client.cs
index 5bbf9697d4b9955f4eacc13fc7aa45200eb3dfd0..86990912885fee07f71c046bf69ba47495fb51e5 100644
--- a/Lightquark.NET/Client.cs
+++ b/Lightquark.NET/Client.cs
@@ -64,6 +64,7 @@ public partial class Client : ObservableObject
     private HttpClient _httpClient = new();
     private User? _currentUser;
     private IMapper _mapper;
+    private Timer _stupidTimer;
 
     public Client(ILogger? logger = null)
     {
@@ -82,6 +83,21 @@ public partial class Client : ObservableObject
             cfg.CreateMap<Channel, Channel>().ForAllMembers(opts => opts.Condition((_, _, srcMember) => srcMember != null));
         });
         _mapper = mapperConfig.CreateMapper();
+        var today = DateTimeOffset.Now;
+        var tomorrow = DateTimeOffset.Now.AddDays(1).Date;
+        var timeUntilMidnight = (tomorrow - today).Add(TimeSpan.FromMilliseconds(100));
+        _stupidTimer = new Timer(_ =>
+        {
+            foreach (var keyValuePair in Messages)
+            {
+                foreach (var message in keyValuePair.Value)
+                {
+                    message.UpdateTime();
+                }
+            }
+            // party time its midnight
+        }, null, timeUntilMidnight, TimeSpan.FromDays(1));
+
     }
 
     
diff --git a/Lightquark.NET/ClientMethods/Gateway.cs b/Lightquark.NET/ClientMethods/Gateway.cs
index ae5c87b8a1a168809c95f8dff5f1bf1d67e088c6..d326035753c42b74f6e2032047f8c352a24d7f6d 100644
--- a/Lightquark.NET/ClientMethods/Gateway.cs
+++ b/Lightquark.NET/ClientMethods/Gateway.cs
@@ -104,9 +104,14 @@ public partial class Client
                         }
                         break;
                     case "messageCreate":
+                    case "messageUpdate":
                         var messageCreateMessage = ParseGateway<MessageCreateGatewayMessage>(msg.Text!);
                         MessageFromObject(messageCreateMessage.Message, messageCreateMessage.ChannelId);
                         break;
+                    case "messageDelete":
+                        var messageDeleteMessage = ParseGateway<MessageCreateGatewayMessage>(msg.Text!);
+                        RemoveMessage(messageDeleteMessage.Message.Id, messageDeleteMessage.ChannelId);
+                        break;
                     case "gatekeeperMeasure":
                         var measureMessage = ParseGateway<MeasureGatewayMessage>(msg.Text!);
                         GatekeeperMeasure(measureMessage);
diff --git a/Lightquark.NET/ClientMethods/Message.cs b/Lightquark.NET/ClientMethods/Message.cs
index 72a532a74d7f225109942d07b75fecf3fead6a38..ea2572aa6a340bfecac07794309732fbdda125d1 100644
--- a/Lightquark.NET/ClientMethods/Message.cs
+++ b/Lightquark.NET/ClientMethods/Message.cs
@@ -1,6 +1,7 @@
 using System.Collections.ObjectModel;
 using Lightquark.NET.Objects;
 using Lightquark.NET.Objects.Reply;
+using Lightquark.NET.Util;
 using MongoDB.Bson;
 using Newtonsoft.Json;
 using Serilog;
@@ -45,6 +46,22 @@ public partial class Client
         }
     }
 
+    public async Task<List<Message>> GetMessages(ObjectId channelId, int limit = 50) 
+    {
+        try
+        {
+            var res = await CallRpc<GetMessagesResponse>("GET", $"{Version}/channel/{channelId}/messages?limit={limit}", null);
+            if (!res.Request.Success) throw new ApiException($"Failed to retrieve messages for {channelId}: {res.Request.StatusCode} {res.Response.Message}");
+
+            return res.Response.Messages;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Failed to retrieve messages in channel {ChannelId}: {ExMsg}", channelId, ex.Message);
+            throw;
+        }
+    }
+
     private void MessageFromObject(Message message, ObjectId? channelId = null)
     {
         channelId ??= message.ChannelId;
@@ -54,17 +71,52 @@ public partial class Client
             Log.Information("Channel {ChannelId} has no prior messages, creating collection", channelId);
             messageCollection = [];
             Messages[channelId.Value] = messageCollection;
+            Task.Run(async () =>
+            {
+                try
+                {
+                    var messages = await GetMessages(channelId.Value);
+                    foreach (var msg in messages)
+                    {
+                        messageCollection.Add(msg);
+                    }
+                }
+                catch (Exception)
+                {
+                    // Unfortunate
+                }
+            });
         }
         // If no message with same id in collection add message
-        if (messageCollection.All(m => m.Id != message.Id))
+        var existingMessage = messageCollection.FirstOrDefault(m => m.Id == message.Id);
+        if (existingMessage == null)
         {
             Log.Information("New message {MessageId} in channel {ChannelId}", message.Id, channelId.Value);
             messageCollection.Add(message);
         }
+        else
+        {
+            Log.Information("Edited message {MessageId} in channel {ChannelId}", message.Id, channelId.Value);
+            var index = messageCollection.IndexOf(existingMessage);
+            messageCollection[index] = message;
+        }
+    }
+
+    private void RemoveMessage(ObjectId messageId, ObjectId channelId)
+    {
+        if (!Messages.TryGetValue(channelId, out var messageCollection)) return; // nothing to delete
+        var message = messageCollection.FirstOrDefault(m => m.Id == messageId);
+        if (message == null) return; // welp we dont have it
+        messageCollection.Remove(message);
     }
 }
 
 public record MessageSendResponse : BaseReplyResponse
 {
-    
+// lol apparently we dont need anything from here?    
+}
+
+public record GetMessagesResponse : BaseReplyResponse
+{
+    public List<Message> Messages { get; set; }
 }
\ No newline at end of file
diff --git a/Lightquark.NET/Objects/Message.cs b/Lightquark.NET/Objects/Message.cs
index 11f14f5fabf6ac685ff511d607400afa1d3451c2..1b1365a994cc55703f0c2524a962ef06a4971397 100644
--- a/Lightquark.NET/Objects/Message.cs
+++ b/Lightquark.NET/Objects/Message.cs
@@ -155,6 +155,37 @@ public class Message() : ObservableObject
         }
     }
 
+    public void UpdateTime()
+    {
+        OnPropertyChanged(nameof(PrettyTime));
+        OnPropertyChanged(nameof(PrettyTimePrecise));
+    }
+    
+    [JsonIgnore]
+    public string PrettyTime
+    {
+        get
+        {
+            var date = DateTimeOffset.FromUnixTimeMilliseconds(Timestamp).ToLocalTime();
+            if (date.Date == DateTime.Today)
+            {
+                return $"Today at {date:H:mm}";
+            }
+
+            return $"{date:dd.MM.yyyy H:mm}";
+        }
+    }
+    
+    [JsonIgnore]
+    public string PrettyTimePrecise
+    {
+        get
+        {
+            var date = DateTimeOffset.FromUnixTimeMilliseconds(Timestamp).ToLocalTime();
+            return $"{date:dd.MM.yyyy H:mm:ss.fff}";
+        }
+    }
+
     private ObjectId _id;
     private ObjectId _authorId;
     private string? _content;
diff --git a/Lightquark.NET/Util/ApiException.cs b/Lightquark.NET/Util/ApiException.cs
new file mode 100644
index 0000000000000000000000000000000000000000..77d2e0f8f0405d7ba0b22f620705558529ca1906
--- /dev/null
+++ b/Lightquark.NET/Util/ApiException.cs
@@ -0,0 +1,6 @@
+namespace Lightquark.NET.Util;
+
+public class ApiException(string message) : Exception(message)
+{
+    
+}
\ No newline at end of file