ember
EMBER — a small team workspace, and the first app built entirely on the topic surface ([[M256]]/[[M261]]/[[M262]]).
12 source files3 test files


Get it
$ osy init ember $ osy launch
The app
app.osy13 lines
// EMBER — a small team workspace, and the first app built entirely on the topic surface ([[M256]]/[[M261]]/[[M262]]). app Ember { use Osysharp.Ui; // Direct messages are Osysharp.Conversations' (model/direct.osy); accounts are Osysharp.Identity's. use Osysharp.Identity@0; use Osysharp.Conversations@0; use Osysharp.Presence@0; model "model/**/*.osy"; tests "tests/**/*.test.osy"; }
model/direct.osy132 lines
// DIRECT MESSAGES, ON OSYSHARP.CONVERSATIONS — a direct message is the kit's `Conversation`, its people are seats // (`Participant`, every one a member's), and what is said is the kit's `Message`. What Ember says is the two things the // kit never knows: who its staff are, and what they may do. The whole feature still turns on one modelling decision: // a conversation is addressed by a ROW, never by the people in it. using Osysharp.Conversations; using Osysharp.Identity; /// A team workspace — everyone signed in is a member, and every seat in a conversation is a member's. There are no /// customers here, so the kit's customer side is never reached. policy IsConversationStaff => IsAuthenticated; // Who sits on a conversation (and any record) with the desk — the Presence kit's question. policy CollaboratesOnRecords => IsConversationStaff; /// Seated in this conversation, and not gone. It reads the seats AS THEY ARE, not as the caller may see them — a hop /// inside a rule resolves against the data — which is what lets a person who created nothing read the conversation /// they were added to. policy IsSeatedIn(Guid conversation) => Osysharp.Conversations.Participant.Any(p => p.Conversation.Id == conversation && p.Member == user && p.LeftAt == null); partial entity Conversation { security { allow read, update when IsAuthenticated where IsSeatedIn(Id); allow create when IsAuthenticated; } } // ⛔ SEATING YOURSELF IS THE WHOLE OF JOINING, so `Member == user` would not be a gate — it would be the door standing // open. An earlier rule here read `allow create where Person == user || …`, and its first disjunct let anybody // signed in insert their own seat against ANY conversation; the read rules then admitted them to all of it. What is // left says the only thing worth saying: you may seat somebody in a conversation you are ALREADY IN — or one you // just opened. // // ⚑ THE `CreatedBy` ARM IS THE BOOTSTRAP AND IT IS NOT A LOOPHOLE. `OpenDirect` seats two people into a brand-new // conversation, and the FIRST seat is written when the conversation has none — the create rule is checked against // the row as written, so the membership arm alone refuses the app's own opening move. `CreatedBy` is stamped by the // platform on system authority and no grant can write it, so "the person who opened this conversation" is a fact an // attacker cannot supply, and it admits you to a conversation you made, never to one somebody else did. // // ⚠ The update is FIELD-scoped for the same reason: re-pointing your own seat at somebody else's conversation would be // the same break wearing a different verb. A seat's read mark is the only thing about it its occupant changes. partial entity Participant { security { allow read when IsAuthenticated where IsSeatedIn(Conversation.Id); allow create when IsAuthenticated where IsSeatedIn(Conversation.Id) || Conversation.CreatedBy == user.Id; allow update LastReadSequence, LastReadAt when IsAuthenticated where Member == user; } } partial entity ConversationEntry { security { allow read when IsAuthenticated where IsSeatedIn(Conversation.Id); } } partial entity Message { security { allow read, create when IsAuthenticated where IsSeatedIn(Conversation.Id); } } // The kit's bookkeeping for channels, merges and erasure — a team's direct messages have no channel but the web and no // customers, so nothing here reaches them, and they stay denied. [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity MessageDelivery { } [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity ConversationThreadKey { } [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity PartyErasure { } [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity PartyMerge { } [SuppressWarning("IMPORTED_ENTITY_HAS_NO_POLICY")] partial entity ConversationSplit { } class DirectLine { public string Body; public string Author; public DateTime SentAt; } topic DirectFeed(Guid conversationId) { Candidates = u => Osysharp.Conversations.Participant.Any(p => p.Conversation.Id == conversationId && p.Member == u && p.LeftAt == null); Carries = DirectLine; } topic DirectPresence(Guid conversationId) { Candidates = u => Osysharp.Conversations.Participant.Any(p => p.Conversation.Id == conversationId && p.Member == u && p.LeftAt == null); Carries = Seat; Presence = true; } /// The direct conversation between the caller and `other` — the one they already have, or a new one with the two of /// them seated. A conversation with a subject is a named group, never "the" conversation with somebody. Conversation OpenDirect(Principal other) { var me = Session.CurrentUser; if (other == me) { return null; } var visible = Participant.Where(p => p.LeftAt == null).Include(p => p.Conversation).ToList(); foreach (var theirs in visible.Where(p => p.Member == other)) { if (theirs.Conversation.Subject == null && visible.Where(p => p.Conversation == theirs.Conversation).Count() == 2) { return theirs.Conversation; } } var conversation = new Conversation { StartedAt = DateTime.UtcNow }; Seat(conversation, me); Seat(conversation, other); return conversation; } void SendDirect(Guid conversationId, string body) { var text = body.Trim(); if (text == "") { return; } if (TrySlashCommand(text)) { return; } var conversation = Conversation.Where(c => c.Id == conversationId).FirstOrDefault(); if (conversation == null) { return; } var me = Session.CurrentUser; // ⚠ `Session.CurrentUser` IS ABSENT FOR A SIGNED-OUT CALLER, and nothing about this function's security // declaration makes it present — a `deny` refuses the WRITE, it does not give you an author. Saying so // here fails where the mistake is, instead of writing a row whose author is nothing. if (me == null) { throw new Exception("Sign in first."); } var sent = Osysharp.Conversations.Post(conversation, text); // qualified: `Post` here is the channel post DirectFeed.For(conversationId).Publish(new DirectLine { Body = text, Author = me.DisplayName, SentAt = sent.At }); var href = "/d/" + conversationId; foreach (var them in Participant.Where(p => p.Conversation == conversation && p.LeftAt == null).Include(p => p.Member).ToList()) { if (them.Member != null && them.Member != me) { Inbox.For(them.Member.Id).Publish(new Nudge { Kind = NudgeKind.Direct, From = me.DisplayName, Preview = text, Href = href, }); } } NudgeMentions(text, href, me); Osysharp.Conversations.MarkRead(conversation); // qualified: `MarkRead` here is a channel's }
model/identity.osy58 lines
// Ember's credentials. No special access is minted, and a forged ticket buys nothing (the server re-verifies every // request). An account is Osysharp.Identity's `Principal` — its address, its name — and Ember adds the password and // where you are. using Osysharp.Identity; [Role] enum AppRole { Authenticator, Member } enum StatusKind { Available, Busy, Away } entity User : Principal { [MaxLength(200)] string PasswordHash; StatusKind Status = StatusKind.Available; // defaulted, so `[Required]` could never fire [MaxLength(80)] string StatusMessage = ""; security { allow read when IsAuthenticated || IsAuthenticator; allow create, update when IsAuthenticator; allow update where Id == user.Id; deny read PasswordHash when !IsAuthenticator; } } [AuthMethod] string Signup(string email, string password) { if (password.Length < 8) { return ""; } if (User.Where(x => x.Email == email).FirstOrDefault() != null) { return ""; } var handle = email; var at = email.IndexOf("@"); if (at > 0) { handle = email.Substring(0, at); } var u = new User { Email = email, PasswordHash = Security.HashPassword(password), DisplayName = handle }; return Security.IssueJwt(u.Id, u.Email); } [AuthMethod] string Login(string email, string password) { var u = User.Where(x => x.Email == email).FirstOrDefault(); if (u == null) { Security.VerifyPassword(password); return ""; } if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); } return ""; } // Audit redaction is EXPLICIT. The entity-change trail records every changed property's old AND new value verbatim // into a durable, exportable log, and the `deny read` on the credential does not cover it — that governs READERS, // and the audit writer is not one. Named here, the trail records THAT the field changed and never its value. app.Audit = new AuditConfig { Redact = new AuditRedaction { Properties = [User.PasswordHash] }, }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, };
model/inbox.osy33 lines
// THE INBOX — the piece that makes everything else reach somebody who is not already looking at it. using Osysharp.Identity; enum NudgeKind { Direct, Mention, } class Nudge { public NudgeKind Kind; public string From; public string Preview; public string Href; } topic Inbox(Guid personId) { Candidates = u => u.Id == personId; Publishers = _ => true; Carries = Nudge; } void NudgeMentions(string text, string href, Principal author) { var words = text.Split(" "); foreach (var person in User.ToList()) { if (person != author && words.Any(w => w == "@" + person.DisplayName)) { Inbox.For(person.Id).Publish(new Nudge { Kind = NudgeKind.Mention, From = author.DisplayName, Preview = text, Href = href, }); } } }
model/status.osy35 lines
// SAYING WHERE YOU ARE — `/away back at 3`, `/busy in a meeting`, `/back`. // ⭐ ONE ENTRY POINT FOR BOTH ROOMS. A channel and a direct message both call this before they post, so `/away` // means the same thing in either — and a command added here is added to both. bool TrySlashCommand(string body) { var text = body.Trim(); if (!text.StartsWith("/")) { return false; } var verb = text; var rest = ""; var space = text.IndexOf(" "); if (space > 0) { verb = text.Substring(0, space); rest = text.Substring(space + 1).Trim(); } if (verb == "/away") { SetStatus(StatusKind.Away, rest); return true; } if (verb == "/busy") { SetStatus(StatusKind.Busy, rest); return true; } if (verb == "/back") { SetStatus(StatusKind.Available, ""); return true; } if (verb == "/available") { SetStatus(StatusKind.Available, ""); return true; } return true; } void SetStatus(StatusKind kind, string message) { // Ember's own kind of account — `User` is its one, so that is what `Session.CurrentUser` is. var me = Session.CurrentUser; // ⚠ `Session.CurrentUser` IS ABSENT FOR A SIGNED-OUT CALLER, and nothing about this function's security // declaration makes it present — a `deny` refuses the WRITE, it does not give you an author. Saying so // here fails where the mistake is, instead of writing a row whose author is nothing. if (me == null) { throw new Exception("Sign in first."); } me.Status = kind; me.StatusMessage = kind == StatusKind.Available ? "" : message; }
model/theme.osy45 lines
// EMBER'S DESIGN LANGUAGE — "editorial dark". theme Ember { Colors { Surface0 = Modes.Of(light: "#faf8f5", dark: "#0e0d0c"); // page canvas Surface1 = Modes.Of(light: "#ffffff", dark: "#171614"); // panel: the rail, the composer, a card Surface2 = Modes.Of(light: "#f1ede7", dark: "#221f1c"); // raised: a hovered row, the selected channel // ⚠ THE PRIMARY TEXT COLOUR IS `OnBg`, WHICH IS THE KIT'S OWN NAME — deliberately, not incidentally. This // block used to declare a `TextPrimary` beside it holding the identical pair, read by nothing while `OnBg` // was read by the pages and by every kit control. A second spelling of a name the kit already has is how an // app ends up with two of everything, and it drifts on the first edit to either. TextSecondary = Modes.Of(light: "#6b6156", dark: "#9c928a"); OnBg = Modes.Of(light: "#17140f", dark: "#f2ede4"); Border = Modes.Of(light: "#e7e0d6", dark: "#2b2724"); FillAccent = Modes.Of(light: "#c2410c", dark: "#fb923c"); FillSuccess = Modes.Of(light: "#15803d", dark: "#4ade80"); FillDanger = Modes.Of(light: "#b91c1c", dark: "#f87171"); FillMuted = Modes.Of(light: "#a8a29e", dark: "#57534e"); Primary = Modes.Of(light: Palette.From("#c2410c"), dark: Palette.From("#fb923c")); OnPrimary = Modes.Of(light: "#fffaf5", dark: "#1a1310"); Surface = Modes.Of(light: "#ffffff", dark: "#171614"); // = Surface1 OnSurface = Modes.Of(light: "#17140f", dark: "#f2ede4"); // = OnBg Muted = Modes.Of(light: "#f1ede7", dark: "#221f1c"); // = Surface2 — the kit's hover fill Danger = Modes.Of(light: "#b91c1c", dark: "#f87171"); Success = Modes.Of(light: "#15803d", dark: "#4ade80"); Warning = Modes.Of(light: "#b45309", dark: "#fbbf24"); } Radius { Control = "10px"; Card = "14px"; Pill = "999px"; } Space { Tick = "4px"; Step = "8px"; Beat = "14px"; Rest = "22px"; Span = "36px"; } Font { Sans = "\"Inter\", system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif"; Mono = "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, monospace"; } FontSize { Micro = "11px"; Caption = "12px"; Body = "14px"; Section = "16px"; Title = "22px"; Display = "30px"; } FontWeight { Regular = 400; Medium = 500; Semibold = 600; Bold = 700; } }
model/workspace.osy104 lines
// EMBER'S WORKSPACE — channels, membership, posts, and the two topics. using Osysharp.Identity; entity Channel { [Required("Name the channel — it is how people find it in the sidebar."), MaxLength(60)] string Name; [MaxLength(160)] string Purpose = ""; security { allow read, create when IsAuthenticated; } } [Unique(Channel, Person)] entity Membership { [Required("A membership is a membership of one channel.")] Channel Channel; [Required("A membership is somebody's — say whose.")] Principal Person; [Required("Stamp the read mark; the unread count is derived from it.")] DateTime LastReadAt; // A channel is public here — `Channel` grants create and read to anyone signed in, and `JoinChannel` is a verb the // app offers on purpose — so seating YOURSELF in one is the intended act, unlike a direct conversation's `Party`. // The update is still field-scoped: entity-wide it would reach `Channel`, and re-pointing your own membership row // at a channel you were never in is joining it by another name. security { allow read where Person == user; allow create where Person == user; allow update LastReadAt where Person == user; } } entity Post { [Required("A post belongs to one channel.")] Channel Channel; [Required("Record who wrote it — the read rule and the screen both name them.")] Principal Author; [Required("Write something — an empty post is not one."), MaxLength(2000)] string Body; [Required("Stamp when it was sent; the channel is ordered by it.")] DateTime SentAt; security { allow read where Membership.Any(m => m.Channel == Channel && m.Person == user); allow create where Membership.Any(m => m.Channel == Channel && m.Person == user); } } class PostLine { public string Body; public string Author; public DateTime SentAt; } class Seat { public Principal Person; public string Activity; } topic ChannelFeed(Guid channelId) { Candidates = u => Membership.Any(m => m.Channel.Id == channelId && m.Person == u); Carries = PostLine; } topic ChannelPresence(Guid channelId) { Candidates = u => Membership.Any(m => m.Channel.Id == channelId && m.Person == u); Carries = Seat; Presence = true; } Channel CreateChannel(string name, string purpose) { var me = Session.CurrentUser; var channel = new Channel { Name = name, Purpose = purpose }; new Membership { Channel = channel, Person = me, LastReadAt = DateTime.UtcNow }; return channel; } void JoinChannel(Channel channel) { var me = Session.CurrentUser; if (Membership.Any(m => m.Channel == channel && m.Person == me)) { return; } new Membership { Channel = channel, Person = me, LastReadAt = DateTime.UtcNow }; } void PostMessage(Channel channel, string body) { var text = body.Trim(); if (text == "") { return; } if (TrySlashCommand(text)) { return; } var me = Session.CurrentUser; // ⚠ `Session.CurrentUser` IS ABSENT FOR A SIGNED-OUT CALLER, and nothing about this function's security // declaration makes it present — a `deny` refuses the WRITE, it does not give you an author. Saying so // here fails where the mistake is, instead of writing a row whose author is nothing. if (me == null) { throw new Exception("Sign in first."); } var sent = DateTime.UtcNow; new Post { Channel = channel, Author = me, Body = text, SentAt = sent }; ChannelFeed.For(channel.Id).Publish(new PostLine { Body = text, Author = me.DisplayName, SentAt = sent }); NudgeMentions(text, "/c/" + channel.Id, me); MarkRead(channel); } void MarkRead(Channel channel) { var me = Session.CurrentUser; var mine = Membership.Where(m => m.Channel == channel && m.Person == me).FirstOrDefault(); if (mine == null) { return; } mine.LastReadAt = DateTime.UtcNow; }
model/pages/channel.osy203 lines
[Route("/c/{channelId}")] [Layout(EmberShell)] [Render(CSR)] [Title("Channel · Ember")] component ChannelPage(Guid channelId) { var me = Session.CurrentUser; var channel = Channel.Single(c => c.Id == channelId); live var mine = Membership.Where(m => m.Channel.Id == channelId).ToList(); var history = Post .Where(p => p.Channel.Id == channelId) .Include(p => p.Author) .OrderBy(p => p.SentAt) .ToList(); live var arriving = ChannelFeed.For(channelId).Listen(); live var here = ChannelPresence.For(channelId).Here; live var people = User.OrderBy(u => u.DisplayName).Take(200).ToList(); live var handles = people.Select(u => "@" + u.DisplayName).ToList(); string draft = ""; string announced = "reading"; string lastSeenDraft = ""; int idleTicks = 0; on every (TimeSpan.FromMilliseconds(700)) { if (draft != lastSeenDraft) { lastSeenDraft = draft; idleTicks = 0; } else { idleTicks = idleTicks + 1; } var want = draft != "" && idleTicks < 4 ? "typing" : "reading"; if (want != announced) { announced = want; ChannelPresence.For(channelId).Announce(new Seat { Activity = want }); } } on mount { MarkRead(channel); ChannelPresence.For(channelId).Announce(new Seat { Activity = "reading" }); } // ⛔ THE COMMIT IS NOT OPTIONAL, and its absence was invisible until 2026-08-27. `PostMessage` and `JoinChannel` // take an ENTITY, so they run inside THIS page's unit of work rather than opening their own — the write lands // in the optimistic overlay, the screen updates from it and looks saved, and it is discarded when the page goes // away. Nothing reported it: the demo's own tests pass because a `[Test]` assertion settles to the database // before it reads, so the one path that never commits is the one a person actually uses. action Send() { PostMessage(channel, draft); UnitOfWork.Commit(); draft = ""; } action Join() { JoinChannel(channel); UnitOfWork.Commit(); Navigation.Go("/c/" + channelId); } action Mention(User person) { draft = (draft.TrimEnd() + " @" + person.DisplayName + " ").TrimStart(); } // ⚑ WHAT THE PAGE LOOKS LIKE WHILE THE CHANNEL AND ITS POSTS ARE STILL ON THE WAY. Without this it paints nothing // until the read answers, and a blank screen is indistinguishable from one that hung. It draws // the SHAPE, never the data — a skeleton cannot read the members it stands in for. skeleton { Stack(gap: 0, minH: "100vh", minW: "0") { Skeleton(shape: SkeletonShape.HalfLine); Skeleton(shape: SkeletonShape.Block); Skeleton(shape: SkeletonShape.Control); } } render { Stack(gap: 0, minH: "100vh", minW: "0") { Row(gap: Space.Beat, align: Align.Center, justify: Justify.Between, px: Space.Rest, py: Space.Beat, borderBW: 1, border: Colors.Border, bg: Colors.Surface1) { Stack(gap: "0", minW: "0") { Row(gap: Space.Tick, align: Align.Center) { Text("#", color: Colors.TextSecondary, fontFamily: Font.Mono, fontSize: FontSize.Section); Text(channel.Name, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold); } if (channel.Purpose != "") { Text(channel.Purpose, fontSize: FontSize.Caption, color: Colors.TextSecondary); } } Row(gap: Space.Step, align: Align.Center) { foreach (var p in here) { Row(gap: Space.Tick, align: Align.Center, bg: Colors.Surface2, rounded: Radius.Pill, px: Space.Step, py: Space.Tick) { Box(w: "6px", h: "6px", bg: Colors.FillSuccess, rounded: Radius.Pill); Text(p.Person.DisplayName, fontSize: FontSize.Micro, fontFamily: Font.Mono); if (p.Activity == "typing") { Text("typing…", fontSize: FontSize.Micro, color: Colors.FillAccent, fontFamily: Font.Mono); } if (p.Activity != "typing" && p.Activity != "") { Text(p.Activity, fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } } if (here.Count == 0) { Text("nobody else here", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } } Stack(gap: Space.Beat, grow: 1, px: Space.Rest, py: Space.Rest, overflowY: Overflow.Auto) { if (history.Count == 0 && arriving.Count == 0 && mine.Count > 0) { Stack(gap: Space.Tick, py: Space.Span) { Text("Nothing here yet.", fontSize: FontSize.Section, fontWeight: FontWeight.Medium); Text("Say the first thing.", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono); } } if (mine.Count == 0) { Stack(gap: Space.Tick, py: Space.Span) { Text("#" + channel.Name, fontSize: FontSize.Section, fontWeight: FontWeight.Medium); Text("what is said in here is for members — join below to read it", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono); } } foreach (var p in history) { Row(gap: Space.Beat, align: Align.Start) { Avatar(initials: p.Author.DisplayName, size: Size.Sm); Stack(gap: "2px", minW: "0") { Row(gap: Space.Step, align: Align.Center) { Text(p.Author.DisplayName, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold); Text(p.SentAt.ToString("HH:mm"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } MessageBody(body: p.Body, handles: handles); } } } foreach (var m in arriving) { Row(gap: Space.Beat, align: Align.Start) { Avatar(initials: m.Author, size: Size.Sm); Stack(gap: "2px", minW: "0") { Row(gap: Space.Step, align: Align.Center) { Text(m.Author, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold); Text(m.SentAt.ToString("HH:mm"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } MessageBody(body: m.Body, handles: handles); } } } } if (mine.Count == 0) { Row(gap: Space.Beat, align: Align.Center, justify: Justify.Between, px: Space.Rest, py: Space.Beat, borderTW: 1, border: Colors.Border, bg: Colors.Surface1) { Stack(gap: "2px") { Text("You are not in #" + channel.Name, fontSize: FontSize.Body, fontWeight: FontWeight.Medium); Text("join to read what is said here", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } Button("Join channel", onPress: Join, tone: Tone.Primary); } } if (mine.Count > 0) { Stack(gap: Space.Step, px: Space.Rest, py: Space.Beat, borderTW: 1, border: Colors.Border, bg: Colors.Surface1) { Row(gap: Space.Tick, align: Align.Center, wrap: Wrapping.Wrap) { Text("mention", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); foreach (var person in people.Where(u => u.Id != me.Id)) { Button("@" + person.DisplayName, onPress: () => Mention(person), tone: Tone.Ghost, size: Size.Sm); } } Row(gap: Space.Step, align: Align.Center) { Box(grow: 1, minW: "0") { Field(label: "Message", value: draft, placeholder: "message #" + channel.Name + " — or /away, /busy, /back"); } Button("Send", onPress: Send, tone: Tone.Primary); } } } } } } [Route("/")] [Layout(EmberShell)] [Render(CSR)] [Title("Ember")] component HomePage() { render { Stack(gap: Space.Step, align: Align.Center, justify: Justify.Center, minH: "100vh", p: Space.Span) { Text("Pick a channel", fontSize: FontSize.Title, fontWeight: FontWeight.Semibold); Text("or make one in the rail", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono); } } }
model/pages/direct.osy168 lines
using Osysharp.Conversations; [Route("/d/{conversationId}")] [Layout(EmberShell)] [Render(CSR)] [Title("Direct message · Ember")] component DirectPage(Guid conversationId) { var me = Session.CurrentUser; live var conversation = Conversation.Where(c => c.Id == conversationId).FirstOrDefault(); live var seats = Participant.Where(p => p.Conversation.Id == conversationId && p.LeftAt == null).ToList(); live var people = User.OrderBy(u => u.DisplayName).Take(200).ToList(); live var handles = people.Select(u => "@" + u.DisplayName).ToList(); var history = Message .Where(m => m.Conversation.Id == conversationId) .OrderBy(m => m.Sequence) .ToList(); live var arriving = DirectFeed.For(conversationId).Listen(); live var here = DirectPresence.For(conversationId).Here; string draft = ""; string announced = "reading"; string lastSeenDraft = ""; int idleTicks = 0; on every (TimeSpan.FromMilliseconds(700)) { if (draft != lastSeenDraft) { lastSeenDraft = draft; idleTicks = 0; } else { idleTicks = idleTicks + 1; } var want = draft != "" && idleTicks < 4 ? "typing" : "reading"; if (want != announced) { announced = want; DirectPresence.For(conversationId).Announce(new Seat { Activity = want }); } } on mount { MarkConversationRead(conversationId); DirectPresence.For(conversationId).Announce(new Seat { Activity = "reading" }); } action Send() { SendDirect(conversationId, draft); draft = ""; } action Mention(User person) { draft = (draft.TrimEnd() + " @" + person.DisplayName + " ").TrimStart(); } // ⚑ WHAT THE PAGE LOOKS LIKE WHILE THE CONVERSATION AND ITS LINES ARE STILL ON THE WAY. Without this it paints nothing // until the read answers, and a blank screen is indistinguishable from one that hung. It draws // the SHAPE, never the data — a skeleton cannot read the members it stands in for. skeleton { Stack(gap: 0, minH: "100vh", minW: "0") { Skeleton(shape: SkeletonShape.HalfLine); Skeleton(shape: SkeletonShape.Block); Skeleton(shape: SkeletonShape.Control); } } render { Stack(gap: 0, minH: "100vh", minW: "0") { if (conversation == null) { Stack(gap: Space.Tick, align: Align.Center, justify: Justify.Center, grow: 1, p: Space.Span) { Text("This conversation is not yours", fontSize: FontSize.Section, fontWeight: FontWeight.Medium); Text("a direct message is between the people in it", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono); } } if (conversation != null) { Row(gap: Space.Beat, align: Align.Center, justify: Justify.Between, px: Space.Rest, py: Space.Beat, borderBW: 1, border: Colors.Border, bg: Colors.Surface1) { Row(gap: Space.Step, align: Align.Center, minW: "0") { foreach (var them in people.Where(u => u.Id != me.Id && seats.Any(s => s.Member != null && s.Member.Id == u.Id))) { Row(gap: Space.Tick, align: Align.Center) { Avatar(initials: them.DisplayName, size: Size.Sm); Text(them.DisplayName, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold); if (them.Status != StatusKind.Available) { Row(gap: Space.Tick, align: Align.Center, bg: Colors.Surface2, rounded: Radius.Pill, px: Space.Step, py: Space.Tick) { Box(w: "6px", h: "6px", rounded: Radius.Pill, bg: them.Status == StatusKind.Busy ? Colors.FillDanger : Colors.FillMuted); Text(them.Status == StatusKind.Busy ? "busy" : "away", fontSize: FontSize.Micro, fontFamily: Font.Mono, color: Colors.TextSecondary); if (them.StatusMessage != "") { Text(them.StatusMessage, fontSize: FontSize.Micro, fontFamily: Font.Mono); } } } } } if (conversation.Subject != null) { Text(conversation.Subject!, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold); } } Row(gap: Space.Step, align: Align.Center) { foreach (var seat in here.Where(p => p.Person.Id != me.Id && p.Activity == "typing")) { Text(seat.Person.DisplayName + " is typing…", fontSize: FontSize.Micro, color: Colors.FillAccent, fontFamily: Font.Mono); } Text("direct", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } Stack(gap: Space.Beat, grow: 1, px: Space.Rest, py: Space.Rest, overflowY: Overflow.Auto) { if (history.Count == 0 && arriving.Count == 0) { Stack(gap: Space.Tick, py: Space.Span) { Text("Nothing said yet.", fontSize: FontSize.Section, fontWeight: FontWeight.Medium); Text("this thread is just the two of you", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono); } } // Each message as its author was named when they wrote it (the kit keeps that on the row). foreach (var m in history) { Row(gap: Space.Beat, align: Align.Start) { Avatar(initials: m.ByName, size: Size.Sm); Stack(gap: "2px", minW: "0") { Row(gap: Space.Step, align: Align.Center) { Text(m.ByName, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold); Text(m.At.ToString("HH:mm"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } MessageBody(body: m.Text, handles: handles); } } } foreach (var l in arriving) { Row(gap: Space.Beat, align: Align.Start) { Avatar(initials: l.Author, size: Size.Sm); Stack(gap: "2px", minW: "0") { Row(gap: Space.Step, align: Align.Center) { Text(l.Author, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold); Text(l.SentAt.ToString("HH:mm"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } MessageBody(body: l.Body, handles: handles); } } } } Stack(gap: Space.Step, px: Space.Rest, py: Space.Beat, borderTW: 1, border: Colors.Border, bg: Colors.Surface1) { Row(gap: Space.Tick, align: Align.Center, wrap: Wrapping.Wrap) { Text("mention", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); foreach (var person in people.Where(u => u != me)) { Button("@" + person.DisplayName, onPress: () => Mention(person), tone: Tone.Ghost, size: Size.Sm); } } Row(gap: Space.Step, align: Align.Center) { Box(grow: 1, minW: "0") { Field(label: "Message", value: draft, placeholder: "write a direct message — or /away, /busy, /back"); } Button("Send", onPress: Send, tone: Tone.Primary); } } } } } }
model/pages/login.osy56 lines
[Route("/login")] [AllowAnonymous] [Render(CSR)] [Title("Ember")] component LoginPage() { string email = ""; string password = ""; string problem = ""; action SignIn() { var ticket = Login(email, password); if (ticket == "") { problem = "That email and password do not match an account."; return; } Session.SignIn(ticket); } action CreateAccount() { var ticket = Signup(email, password); if (ticket == "") { problem = "Pick a password of at least 8 characters, and an email nobody has used yet."; return; } Session.SignIn(ticket); } render { Stack(bg: Colors.Surface0, color: Colors.OnBg, minH: "100vh", align: Align.Center, justify: Justify.Center, p: Space.Rest) { Stack(gap: Space.Rest, w: "100%", maxW: "380px") { Stack(gap: Space.Tick) { Row(gap: Space.Step, align: Align.Center) { Box(w: "10px", h: "10px", bg: Colors.FillAccent, rounded: Radius.Pill); Text("ember", fontSize: FontSize.Display, fontWeight: FontWeight.Bold); } Text("a small room for a small team", fontSize: FontSize.Caption, color: Colors.TextSecondary, fontFamily: Font.Mono); } Stack(gap: Space.Beat, bg: Colors.Surface1, p: Space.Rest, rounded: Radius.Card, borderW: 1, border: Colors.Border) { Field(label: "Email", value: email, placeholder: "[email protected]", type: "email"); Field(label: "Password", value: password, placeholder: "at least 8 characters", type: "password"); if (problem != "") { Text(problem, fontSize: FontSize.Caption, color: Colors.FillDanger); } Stack(gap: Space.Step) { Button("Sign in", onPress: SignIn, tone: Tone.Primary); Button("Create account", onPress: CreateAccount, tone: Tone.Ghost); } } Row(justify: Justify.Center) { Text("Nothing here is real. Make an account and use it.", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } } } }
model/pages/message-body.osy38 lines
class MessageRun { public string Text; public bool IsMention; } MessageRun[] MessageRuns(string body, string[] handles) { var runs = new List<MessageRun>(); var plain = ""; foreach (var word in body.Split(" ")) { if (handles.Any(h => h == word)) { if (plain != "") { runs.Add(new MessageRun { Text = plain, IsMention = false }); plain = ""; } runs.Add(new MessageRun { Text = word, IsMention = true }); } else { if (plain == "") { plain = word; } else { plain = plain + " " + word; } } } if (plain != "") { runs.Add(new MessageRun { Text = plain, IsMention = false }); } return runs; } [Composable] component MessageBody(string body, string[] handles) { render { Row(gap: Space.Tick, wrap: Wrapping.Wrap, align: Align.Baseline, minW: "0") { foreach (var run in MessageRuns(body, handles)) { if (run.IsMention) { Text(run.Text, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold, color: Colors.FillAccent); } if (!run.IsMention) { Text(run.Text, fontSize: FontSize.Body); } } } } }
model/pages/shell.osy222 lines
// ⚠ SUPPRESSED, AND THE PLATFORM'S OWN DOCS SAY WHY. `Session.CurrentUser` is null for a visitor, so `me.Id` // below reads as a nullable hop — but `osy docs ui-current-user#anonymous` is explicit that the null is NOT the // gate: "a page that requires a user should require one at the route". Every page wearing this layout // (`/c/{id}`, `/d/{id}`, the home redirect) is protected; the one `[AllowAnonymous]` page in this app, // `/login`, wears no layout at all. So `me` is present here by the ROUTE, which the linter cannot see, and a // `me == null` branch in a shell would be a second gate contradicting the first. using Osysharp.Conversations; [SuppressWarning("correctness-member-read-off-a-nullable")] [Layout] component EmberShell() { var me = Session.CurrentUser; live var mine = Membership.Include(m => m.Channel).Take(200).ToList(); // The unread badges count over the newest 1000 posts and messages — a page is sent at most its Take (D-READLIMITS1), // and a badge past that many unread says the same thing to the reader. live var visible = Post.Include(p => p.Channel).OrderByDescending(p => p.SentAt).Take(1000).ToList(); // Every seat in a conversation the caller is in (the rules decide which), and what was said in them. live var seats = Participant.Where(p => p.LeftAt == null).Include(p => p.Conversation).Take(500).ToList(); live var messages = Message.OrderByDescending(m => m.Sequence).Take(1000).ToList(); live var people = User.OrderBy(u => u.DisplayName).Take(200).ToList(); live var nudges = Inbox.For(me.Id).Listen(); string newName = ""; int dismissed = 0; action Create() { var name = newName.Trim(); if (name == "") { return; } var made = CreateChannel(name, ""); newName = ""; Navigation.Go("/c/" + made.Id); } action SignOut() { Session.SignOut(); } // See the note in `channel.osy`: `OpenDirect` takes an entity, so it runs in THIS page's unit of work and the // conversation it creates is only an overlay row until this commits. action Open(User person) { var conversation = OpenDirect(person); UnitOfWork.Commit(); Navigation.Go("/d/" + conversation.Id); } action Dismiss() { dismissed = nudges.Count; } render { Row(gap: 0, minH: "100vh", bg: Colors.Surface0, color: Colors.OnBg, align: Align.Start) { Stack(gap: Space.Rest, w: "260px", minW: "260px", bg: Colors.Surface1, p: Space.Beat, minH: "100vh", borderRW: 1, border: Colors.Border) { Row(gap: Space.Step, align: Align.Center) { Box(w: "10px", h: "10px", bg: Colors.FillAccent, rounded: Radius.Pill); Text("ember", fontSize: FontSize.Title, fontWeight: FontWeight.Bold); } Stack(gap: Space.Step) { Text("CHANNELS", fontSize: FontSize.Micro, fontWeight: FontWeight.Semibold, color: Colors.TextSecondary, fontFamily: Font.Mono); Stack(gap: Space.Tick) { foreach (var m in mine) { Link(href: "/c/" + m.Channel.Id, textDecoration: TextDecoration.None, color: Colors.OnBg) { // The row is a labelled container named for its channel, so a screen reader announces it by the // channel's name. // ⚠ THIS SAID a test scopes into it with `within: "design"`, AND THAT SPELLING IS REFUSED — a scope // selects a CONTAINER, and a rendered name is TEXT with nothing tying it to the row it sits in // (`testing-scope-names-text-not-a-container`, MUST). A test passes the ROW, and the row here is // the MEMBERSHIP `m` — what this `foreach` iterates — not `m.Channel`. Row(gap: Space.Step, align: Align.Center, justify: Justify.Between, px: Space.Step, py: Space.Tick, rounded: Radius.Control, role: UiRole.Group, label: m.Channel.Name) { Row(gap: Space.Tick, align: Align.Center, minW: "0") { Text("#", color: Colors.TextSecondary, fontFamily: Font.Mono); Text(m.Channel.Name, fontSize: FontSize.Body); } if (visible.Where(p => p.Channel.Id == m.Channel.Id && p.SentAt > m.LastReadAt).Count() > 0) { Box(bg: Colors.FillAccent, color: Colors.Surface1, rounded: Radius.Pill, px: Space.Step, py: "1px") { Text(visible.Where(p => p.Channel.Id == m.Channel.Id && p.SentAt > m.LastReadAt).Count(), fontSize: FontSize.Micro, fontWeight: FontWeight.Bold, fontFamily: Font.Mono); } } } } } } if (mine.Count == 0) { Text("none yet — make one below", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } Stack(gap: Space.Step) { Text("NEW CHANNEL", fontSize: FontSize.Micro, fontWeight: FontWeight.Semibold, color: Colors.TextSecondary, fontFamily: Font.Mono); Field(label: "Name", value: newName, placeholder: "design"); // A blank name has nothing to create, so the button SAYS so rather than accepting the press and // doing nothing — `ui-inert-affordance`, which could not see this shape until the guard-after-a-local // case was covered. Button("Create", onPress: Create, tone: Tone.Ghost, size: Size.Sm, disabled: newName.Trim() == ""); } Stack(gap: Space.Step) { Text("DIRECT MESSAGES", fontSize: FontSize.Micro, fontWeight: FontWeight.Semibold, color: Colors.TextSecondary, fontFamily: Font.Mono); Stack(gap: Space.Tick) { foreach (var mine in seats.Where(p => p.Member != null && p.Member.Id == me.Id)) { Link(href: "/d/" + mine.Conversation.Id, textDecoration: TextDecoration.None, color: Colors.OnBg) { Row(gap: Space.Step, align: Align.Center, justify: Justify.Between, px: Space.Step, py: Space.Tick, rounded: Radius.Control) { Row(gap: Space.Tick, align: Align.Center, minW: "0") { if (mine.Conversation.Subject == null) { foreach (var them in people.Where(u => u.Id != me.Id && seats.Any(s => s.Conversation == mine.Conversation && s.Member != null && s.Member.Id == u.Id))) { Text(them.DisplayName, fontSize: FontSize.Body); } } if (mine.Conversation.Subject != null) { Text(mine.Conversation.Subject!, fontSize: FontSize.Body); } } // Unread: what others wrote past the seat's read mark — never your own. if (messages.Where(m => m.Conversation == mine.Conversation && m.Sequence > mine.LastReadSequence && m.ByMember != me).Count() > 0) { Box(bg: Colors.FillAccent, color: Colors.Surface1, rounded: Radius.Pill, px: Space.Step, py: "1px") { Text(messages.Where(m => m.Conversation == mine.Conversation && m.Sequence > mine.LastReadSequence && m.ByMember != me).Count(), fontSize: FontSize.Micro, fontWeight: FontWeight.Bold, fontFamily: Font.Mono); } } } } } } if (seats.Where(p => p.Member != null && p.Member.Id == me.Id).Count() == 0) { Text("none yet — pick somebody below", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } Stack(gap: Space.Step) { Text("PEOPLE", fontSize: FontSize.Micro, fontWeight: FontWeight.Semibold, color: Colors.TextSecondary, fontFamily: Font.Mono); Stack(gap: Space.Tick) { foreach (var person in people.Where(u => u != me)) { Row(gap: Space.Step, align: Align.Center, minW: "0") { Button(person.DisplayName, onPress: () => Open(person), tone: Tone.Ghost, size: Size.Sm); if (person.Status != StatusKind.Available) { Row(gap: Space.Tick, align: Align.Center, minW: "0") { Box(w: "6px", h: "6px", rounded: Radius.Pill, bg: person.Status == StatusKind.Busy ? Colors.FillDanger : Colors.FillMuted); Text(person.StatusMessage != "" ? person.StatusMessage : (person.Status == StatusKind.Busy ? "busy" : "away"), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } } } } if (people.Count < 2) { Text("nobody else has signed up yet", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } Spacer(); Row(gap: Space.Step, align: Align.Center, justify: Justify.Between, borderTW: 1, border: Colors.Border, pt: Space.Beat) { Row(gap: Space.Step, align: Align.Center, minW: "0") { Avatar(initials: me.DisplayName, size: Size.Sm); Stack(gap: "0") { Text(me.DisplayName, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium); foreach (var self in people.Where(u => u.Id == me.Id)) { Row(gap: Space.Tick, align: Align.Center) { Box(w: "6px", h: "6px", rounded: Radius.Pill, bg: self.Status == StatusKind.Available ? Colors.FillSuccess : (self.Status == StatusKind.Busy ? Colors.FillDanger : Colors.FillMuted)); Text(self.StatusMessage != "" ? self.StatusMessage : (self.Status == StatusKind.Available ? "online" : (self.Status == StatusKind.Busy ? "busy" : "away")), fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } } } Row(gap: Space.Tick, align: Align.Center) { ThemeToggle(); IconButton("Sign out", SignOut) { Text("⏻"); } } } } Stack(gap: 0, grow: 1, minW: "0", minH: "100vh") { Outlet; } if (nudges.Count > dismissed) { Stack(gap: Space.Step, position: Position.Fixed, top: "18px", right: "18px", w: "320px", maxH: "70vh", overflowY: Overflow.Auto) { foreach (var n in nudges.Reverse().Take(nudges.Count - dismissed)) { Link(href: n.Href, textDecoration: TextDecoration.None, color: Colors.OnBg) { Stack(gap: Space.Tick, bg: Colors.Surface2, borderW: 1, border: Colors.Border, rounded: Radius.Card, px: Space.Beat, py: Space.Step, shadow: "0 10px 30px rgba(0,0,0,0.35)") { Row(gap: Space.Step, align: Align.Center) { Box(w: "6px", h: "6px", bg: Colors.FillAccent, rounded: Radius.Pill); Text(n.From, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold); if (n.Kind == NudgeKind.Mention) { Text("mentioned you", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } if (n.Kind == NudgeKind.Direct) { Text("messaged you", fontSize: FontSize.Micro, color: Colors.TextSecondary, fontFamily: Font.Mono); } } Text(n.Preview, fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } Row(justify: Justify.End) { Button("Dismiss", onPress: Dismiss, tone: Tone.Ghost, size: Size.Sm); } } } } } }