Files
DodoSSH/src/DodoSSH.Client.Android/Views/VaultsScreen.axaml
T

376 lines
23 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views"
xmlns:contracts="using:DodoSSH.Contracts"
x:Class="DodoSSH.Client.Android.Views.VaultsScreen"
x:DataType="vm:VaultsViewModel"
Background="{StaticResource Canvas}">
<!--
VAULTS, under MORE — and the one screen behind that hub the v2 phone design never drew.
It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because a vault
arrives without being asked for — somebody wraps its key to you from their machine — and the person it
arrives for is at least as likely to be holding a phone as sitting at a desktop. A vault the server has
just put somebody into, visible only on a head they may never have installed, is a membership they
cannot see.
── IT LISTED TEAMS UNTIL THE SCREEN STOPPED BEING ABOUT THEM. ───────────────────────────────────────
The rows are vaults now, and the members under one are the people that vault is shared with. Nothing
about the server changed — it still authorises through a team — and what went is the step where
somebody had to make one before they could share anything. See VaultsViewModel.
So there is no mock-up to depart from. What this departs from instead is the desktop screen over the
same view model, and every difference below is a phone difference rather than a second opinion.
**The desktop's two columns are one.** A 268-pixel vault list beside a members table does not exist at
360dp, so the lists stack in one scrolling column with the vaults at the top. That is the same thing
HOSTS does with the desktop's sidebar and its connect column, and for the same reason.
**Nothing scrolls inside anything.** The desktop caps its members list at 240 pixels so several can sit
above each other in one pane. Here every list is sized to its content and the screen's own ScrollViewer
does all of the scrolling: a list that scrolls inside a page is a region a thumb has to find the edges
of, and three of them on one screen is three ways to get stuck.
◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a
subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a
machine that already holds the key, and this phone is one — so a vaults screen that could only be read
would leave the product's central claim undemonstrated on the head most people carry. REMOVE and
WITHDRAW KEY are the other half of that, and both act on the first press: the view model's
armed-confirmation state covers handing a vault over and nothing else. The desktop guards
them with a tooltip instead, which is a control a touch screen has no way to show. An irreversible
revocation under a thumb with its explanation missing is the wrong trade, so both stay on the
desktop — where the sentence beside them is visible. Handing a vault over is not drawn either, for a
plainer reason: it decides who controls the vault, which is not a thing to do while walking. Nor is
renaming, which is a keyboard on a screen that is otherwise all reading.
◆ **ADD is drawn, where it was not.** The argument against it was that it is an address typed into a
box, a directory lookup, a role picker and a paragraph saying what adding somebody did *not* do — five
controls for an act a colleague at a desktop is already performing. That was a cost argument and it was
wrong about who is holding what: the person who needs to let a colleague into a vault is often the one
away from their desk, and telling them to go and find one is the same answer this head exists to stop
giving. Making a vault was already here on exactly that reasoning.
What it costs is four rows under the members list rather than five controls, because three of them are
the role and are chips rather than a picker — the same chips the host editor wears for tags, so the
choice is visible without being opened. It is gated on being able to administer the vault, so on a
membership where the answer would be a refusal there is nothing to press.
**The paragraph comes with it and is not the optional part.** Adding somebody is a change to what the
server will serve and nothing else; the key still has to be wrapped from a machine that holds it, which
on this head is the bar below. A screen that offered the first and stayed quiet about the second would
be implying the server can hand out access, which is the one claim this product is built to refuse.
**The key-holder list is not drawn.** It is a third list, and what the phone can answer about a vault
is the more useful half of the same question and is on the vault's own row: whether *this* machine can
open it.
**↻ and `+` both, because this screen has more reason to re-read than any other.** Who is in a vault is
not cached — it is read from the server on arrival and again at the end of every command — so the one
thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them
from a colleague's desktop, or a vault they have this moment been added to. On the desktop the
re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and
back, so the button earns its place. It binds to a real command rather than to ShowScreen(Vaults),
which would set Screen to the value it already holds, raise nothing and reload nothing.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto" Height="56" Margin="8,0">
<!--
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
somewhere the layout has removed. See PhoneShell.IsWide.
-->
<Button Grid.Column="0" Classes="icon" Content="←"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Vaults" Margin="4,0" />
<Button Grid.Column="2" Classes="icon" Content="↻" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" Classes="icon accent" Content="+" Command="{Binding NewVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
<!-- ============ a new vault ============ -->
<!--
Above the list rather than in place of it, which is the opposite of what the host and snippet
editors do — and the difference is what the form is about. Those two edit a row that is on screen,
so a card stacked over the list hides the thing being changed. This one is about a vault that does
not exist yet, and the vaults that do are exactly the useful thing to be able to see while naming it.
-->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding IsCreatingVault}">
<StackPanel Spacing="10">
<TextBlock Classes="label" Text="NEW VAULT" />
<TextBox Classes="field" Text="{Binding NewVaultName}" PlaceholderText="name" />
<TextBlock Classes="body"
Text="Its key is made on this phone and nobody else has it. Add people to it once it exists, then share the key with them." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="CREATE"
Command="{Binding CreateVaultCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelNewVaultCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
Status, and it is the empty state as well: the view model writes "locked" and every command's
outcome into the same property. A literal here would be a second voice saying the same thing
slightly differently.
-->
<TextBlock Grid.Row="2" Classes="detail" Margin="18,2,18,6" TextWrapping="Wrap"
Text="{Binding Status}"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- ============ the column ============ -->
<ScrollViewer Grid.Row="3">
<StackPanel Margin="0,0,0,18">
<TextBlock Classes="section" Text="VAULTS" Margin="18,4,18,4" />
<!--
Rows as cards, filled when chosen, which is what HOSTS settled on in v2 and what the radius
ladder calls a card: one item, one rule, one thing you act on. The fill is on the item rather
than on a Border inside it so the rounding the theme draws for selection is the row's own.
-->
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
IsVisible="{Binding HasVaults}" Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Detail}" />
<!--
Whether *this* phone can open it, and whether a rekey is owed. Coloured because both
states need somebody to act; drawn at all only when there is one, so an ordinary vault
carries no line rather than a reassurance nobody reads.
-->
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding HasState}" />
</StackPanel>
<!-- The caller's own role here, which is what says why some of it is read-only. -->
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding RoleLabel}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- ============ the chosen vault ============ -->
<StackPanel IsVisible="{Binding HasSelection}">
<!--
The personal vault has no members and never will, so the members heading is gated on the vault
being one that can have them rather than left standing over an empty list.
-->
<TextBlock Classes="body" Margin="18,18,18,0" IsVisible="{Binding SelectedIsPersonal}"
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright. Make a vault for the things you want to share, and put them in it." />
<StackPanel IsVisible="{Binding SelectedIsShared}">
<TextBlock Classes="section" Text="MEMBERS" Margin="18,18,18,4" />
<TextBlock Classes="body" Margin="18,0,18,4" IsVisible="{Binding HasSharedMembershipWarning}"
Text="{Binding SharedMembershipWarning}" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="54" Margin="14,11">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<!--
◆ The one fact on this row that decides whether the button at the foot of the screen
can do anything: an account with no published identity key has nothing for a vault
key to be wrapped to. One sentence, from the view model, painted twice rather than
written twice — the warning colour is the whole of the difference, and a converter
for it would hide that the two are the same string.
The published case is quiet rather than green. Green on this head means a shell is
open right now, and a published key is a durable fact about an account — borrowing
the status colour for it would be the second meaning that makes the first
unreadable. Only the missing key is coloured, because only it needs answering.
-->
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding KeyState}"
IsVisible="{Binding Member.IsEnrolled}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding KeyState}"
IsVisible="{Binding !Member.IsEnrolled}" />
<!--
A date to the day, or that they have never been here at all. The view model writes
both, and neither is a guess: the server records the account's last authenticated
request at most once an hour, which is what makes a day the honest unit.
-->
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextFaint}"
Text="{Binding LastActive}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="body" Margin="18,10,18,0"
Text="Being in a vault is what lets the server hand somebody its rows. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." />
<!-- ============ ◆ adding somebody ============ -->
<!--
In the scrolling column rather than in the bar at the foot, and that is deliberate. The bar is
raised by choosing a *member* and is about that member; this is about the vault and is
available with nobody chosen at all, so putting it there would mean selecting somebody in
order to add somebody else.
Only for an admin or the owner. The server refuses anybody else and says so, but a box and a
button that exist to produce that refusal are worse than an absence — see
VaultsViewModel.CanAdministerSelected, which is the same gate the desktop uses.
The role is three chips rather than a ComboBox, matching the tag chips in the host editor: the
answer is one of three short words, and a picker would hide two of them behind a tap. Member
is the default because it is what adding a colleague almost always means; viewer would be
safer and would be the wrong default, since an interface whose default is wrong teaches people
to change it without reading it.
-->
<StackPanel IsVisible="{Binding CanAdministerSelected}" Spacing="8" Margin="18,18,18,0">
<TextBlock Classes="section" Text="ADD SOMEBODY" />
<TextBox Classes="field" Text="{Binding NewMemberEmail}"
PlaceholderText="colleague@example.com" />
<TextBlock Classes="label" Text="THEY ARRIVE AS" Margin="0,2,0,0" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="chiptoggle" Classes.worn="{Binding AddsAsViewer}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}">
<TextBlock Classes="mono" FontSize="11.5" Text="VIEWER" />
</Button>
<Button Classes="chiptoggle" Classes.worn="{Binding AddsAsMember}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}">
<TextBlock Classes="mono" FontSize="11.5" Text="MEMBER" />
</Button>
<Button Classes="chiptoggle" Classes.worn="{Binding AddsAsAdmin}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}">
<TextBlock Classes="mono" FontSize="11.5" Text="ADMIN" />
</Button>
</StackPanel>
<Button Classes="primary" Height="44" Content="ADD"
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" />
<!--
The half of the act the server cannot perform, said before it happens rather than discovered
afterwards. Adding somebody from this phone wraps every vault key this phone can open to
them as part of the same command — so on an unlocked handset it is one press, and on a
locked one it is a membership and an owed key, which the status line then says.
-->
<TextBlock Classes="body"
Text="Adding somebody lets the server serve them this vault. It does not let them read it: a vault key is wrapped by a machine that already holds one, so this phone does that too if it can open the vault. An account that has never signed in to this server cannot be added — ask them to sign in once first." />
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ============ ◆ giving somebody the key ============ -->
<!--
Raised over the column when somebody has been chosen, as HOSTS raises its connect bar and SNIPPETS
its insert bar, and for the reason written there: there is no second column to put it in, so it names
what it will do rather than relying on a selection being visible beside the button.
One condition where there used to be two. Sharing needs a member and a vault, and the vault is now
the thing the whole screen is about — a member can only be selected under one, so choosing who is the
only half left to make.
-->
<!-- DeepChrome rather than Chrome, since v5 — the same raised-bar decision SnippetsScreen and
FilesScreen make for their own selection bars; see the remark on PhoneShell's vault header. -->
<Border Grid.Row="4"
IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="9">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="THE KEY TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedVault.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="FOR" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedMember.Name}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
<Button Classes="primary" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
<!--
The sentence the desktop hangs off a tooltip, which a phone cannot show — so it is body text
under the button, where it is read before the tap rather than after it. It is not decoration:
the key-log check proves this server has been consistent with itself and nothing more.
-->
<TextBlock Classes="body"
Text="Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged. That proves the server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
</StackPanel>
</Border>
</Grid>
</UserControl>