Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Docs/DesignLanguage.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,27 @@ presence. Rings compete with the artwork and are hard to read at small sizes.
Where an icon appears without a row, such as in a planet folder, a small dot at
the top right marks unread activity.

## Phone and wide layouts

Valour has two layouts. The phone layout shows one window at a time, with the
sidebar sliding in from the left. The wide layout keeps the sidebar open and
lets windows sit side by side in tabs and splits. Components read
`DeviceInfo.IsMobile` to choose between them.

Browsers, desktop apps, and the Android app choose once at startup from the
device type. The iPhone and iPad app choose from the window size instead: the
wide layout needs a window at least 680 points wide and 500 points tall, which
roughly matches iOS regular width and height size classes. When the window
crosses that line, such as when an iPhone Duo opens or closes or an iPad enters
Split View, the page shell rebuilds itself in the new layout. The dock saves its
windows first and restores them afterward; the phone layout keeps only the
focused tab.

On iOS, the app shell stays inside the left and right safe areas. On iPhone Duo
the status bar becomes a rail along one edge, and on other iPhones in landscape
the camera housing takes one side. The page background shows through those
insets.

## Victor and scenes

Victor is drawn as himself, the dog from the logo, in a few poses: `astronaut`,
Expand Down
11 changes: 11 additions & 0 deletions Valour/Client.Maui/wwwroot/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,14 @@ h1:focus {
background-color: var(--surface-canvas);
}
}

/* Keeps the app clear of the side safe areas: the status bar rail that the
iPhone Duo draws along one edge, and the camera housing on iPhones in
landscape. The page background shows through the inset. */
@supports (-webkit-touch-callout: none) {
.mobile-holder {
box-sizing: border-box;
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
24 changes: 24 additions & 0 deletions Valour/Client/Device/DeviceInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,31 @@

public class DeviceInfo
{
/// <summary>
/// True when the app shows the phone layout: one window at a time and a
/// slide-out sidebar. The iPhone and iPad app can switch layouts while it
/// runs (see <see cref="SetMobileLayout"/>); elsewhere it is set once.
/// </summary>
public static bool IsMobile { get; set; }

/// <summary>
/// Raised after <see cref="IsMobile"/> changes while the app runs, with the
/// new value. The page shell rebuilds the layout when it fires.
/// </summary>
public static event Action<bool> LayoutModeChanged;

/// <summary>
/// Switches between the phone and wide layouts, such as when a foldable
/// iPhone opens or closes or an iPad enters Split View.
/// </summary>
public static void SetMobileLayout(bool mobile)
{
if (IsMobile == mobile)
return;

IsMobile = mobile;
LayoutModeChanged?.Invoke(mobile);
}
public static bool IsEmbedded { get; set; }

/// <summary>
Expand Down
33 changes: 33 additions & 0 deletions Valour/Client/Device/LayoutModeWatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.JSInterop;

namespace Valour.Client.Device;

/// <summary>
/// Chooses the phone or wide layout from the window size in the iPhone and iPad
/// app, and switches it when the window changes size, such as when an iPhone Duo
/// opens or closes. The size rule lives in valourLayout in main.js.
/// </summary>
public sealed class LayoutModeWatcher
{
private static DotNetObjectReference<LayoutModeWatcher> _reference;

/// <summary>
/// Returns whether the window currently fits the phone layout, and starts
/// reporting changes to <see cref="DeviceInfo.SetMobileLayout"/>.
/// </summary>
public static async Task<bool> StartAsync(IJSRuntime js)
{
var wide = await js.InvokeAsync<bool>("valourLayout.isWide");

if (_reference is null)
{
_reference = DotNetObjectReference.Create(new LayoutModeWatcher());
await js.InvokeVoidAsync("valourLayout.watch", _reference);
}

return !wide;
}

[JSInvokable]
public void OnWideLayoutChanged(bool wide) => DeviceInfo.SetMobileLayout(!wide);
}
39 changes: 39 additions & 0 deletions Valour/Client/Pages/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
@page "/planetwiki/{StartPlanetId:long}/{StartPageId:long}"
@page "/planetwiki/{StartPlanetId:long}"
@using Valour.Sdk.Services
@implements IDisposable

@inject ValourClient Client
@inject AuthService AuthService
Expand All @@ -30,6 +31,9 @@
<ProfilePopup />
<CustomDropdownContent />

@* Removed for one render while the layout switches; see OnLayoutModeChanged. *@
@if (!_rebuildingShell)
{
<div class="mobile-holder @MobileClass @EmbeddedClass">
<ToastContainer>
<!-- Modal management -->
Expand Down Expand Up @@ -75,6 +79,7 @@
</ModalRoot>
</ToastContainer>
</div>
}

@* Ctrl+/ or Cmd+/ swaps the app for the ValourOS terminal. *@
<ValourTerminal />
Expand Down Expand Up @@ -103,6 +108,8 @@
[Parameter]
public long? StartPageId { get; set; }

private bool _rebuildingShell;

private string MobileClass => DeviceInfo.IsMobile ? "mobile" : "desktop";

private string EmbeddedClass => DeviceInfo.IsEmbedded ? "embedded" : "";
Expand All @@ -115,16 +122,48 @@

WindowService.FocusedPlanetChanged += OnPlanetChange;
AuthService.LoggedIn += OnLoginAsync;
DeviceInfo.LayoutModeChanged += OnLayoutModeChanged;
}

private async Task OnLoginAsync(User user)
{
await InvokeAsync(StateHasChanged);
}

// Folding an iPhone Duo or resizing an iPad window can switch between the
// phone and wide layouts. Components read DeviceInfo.IsMobile when they are
// created, so the shell is rebuilt instead of updated in place. The dock
// saves its layout first and restores it on load, collapsing it to the
// focused tab for the phone layout. The shell is removed for one render
// before it returns, so the old dock is disposed before the new one
// registers as the main dock.
private void OnLayoutModeChanged(bool mobile) => _ = InvokeAsync(RebuildShellAsync);

private async Task RebuildShellAsync()
{
if (_rebuildingShell)
return;

var dock = WindowService.MainDock;
if (dock?.Layout is not null)
await dock.SaveLayout();

// The launch route was already opened; reopening it would undo where the user went since.
_startRoute = null;

_rebuildingShell = true;
StateHasChanged();
await Task.Yield();

_rebuildingShell = false;
StateHasChanged();
}

public void Dispose()
{
WindowService.FocusedPlanetChanged -= OnPlanetChange;
DeviceInfo.LayoutModeChanged -= OnLayoutModeChanged;
AuthService.LoggedIn -= OnLoginAsync;
}

private Task OnPlanetChange(Planet planet)
Expand Down
8 changes: 6 additions & 2 deletions Valour/Client/ValourApp.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@using Microsoft.Extensions.DependencyInjection
@using Microsoft.Extensions.DependencyInjection
@inject ValourClient Client
@inject LoggingService Logger
@inject IJSRuntime JsRuntime
Expand Down Expand Up @@ -217,7 +217,11 @@
{
if (firstRender)
{
var isMobile = await JsRuntime.InvokeAsync<bool>("IsMobile");
// The iPhone and iPad app pick the layout from the window size and
// switch it live; other hosts decide once from the device type.
var isMobile = DeviceInfo.IsIosApp
? await LayoutModeWatcher.StartAsync(JsRuntime)
: await JsRuntime.InvokeAsync<bool>("IsMobile");
var isEmbedded = await JsRuntime.InvokeAsync<bool>("IsEmbedded");

DeviceInfo.IsMobile = isMobile;
Expand Down
33 changes: 33 additions & 0 deletions Valour/Client/wwwroot/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,39 @@ function IsEmbedded() {
return embedded;
}

// The iPhone and iPad app choose between the phone and wide layouts from the
// window size instead of the device, so an unfolded iPhone Duo or a full-screen
// iPad gets the wide layout, while a folded phone, a phone in landscape, or a
// narrow Split View window gets the phone layout. The thresholds approximate
// Apple's regular width and height size classes.
window.valourLayout = (() => {
const wideQuery = '(min-width: 680px) and (min-height: 500px)';
// Folding animates the window through several sizes; report only where it settles.
const settleMs = 250;
let media = null;
let timer = null;

function isWide() {
return window.matchMedia(wideQuery).matches;
}

function watch(dotnetRef) {
if (media)
return;

media = window.matchMedia(wideQuery);
media.addEventListener('change', () => {
clearTimeout(timer);
timer = setTimeout(() => {
dotnetRef.invokeMethodAsync('OnWideLayoutChanged', media.matches)
.catch(() => { });
}, settleMs);
});
}

return { isWide, watch };
})();

// A readable name for this device, shown in the account's device list when
// linking devices for end-to-end encryption.
function getValourDeviceName(isHybrid) {
Expand Down