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.osy8 lines
// EMBER — a small team workspace, and the first app built entirely on the topic surface ([[M256]]/[[M261]]/[[M262]]). app Ember { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; }
model/direct.osy115 lines
// DIRECT MESSAGES — and the whole feature turns on one modelling decision: a conversation is addressed by a ROW, // never by the people in it. // ⭐ THE GROUP-MEMBERSHIP READ RULE, and it is the one thing here that had to be MEASURED rather than assumed // (2026-08-18, probe app). It reads *down* the relation — "the parties of this conversation" — rather than restating // a join, and it is not circular even though `Party` decides `Party`: a hop inside a rule resolves against the data // AS IT IS, not against the rows the caller may read, which the platform states outright and which the probe // confirmed. Ada creates the conversation; **Bo, who created nothing, reads both party rows; Cy reads neither.** entity Conversation { [MaxLength(60)] string Title = ""; [ForeignKey(Conversation)] Party[] Parties; security { allow read where Parties.Any(p => p.Person == user); allow create when IsAuthenticated; } } [Unique(Conversation, Person)] entity Party { [Required] Conversation Conversation; [Required] User Person; [Required] DateTime LastReadAt; security { allow read where Conversation.Parties.Any(p => p.Person == user); allow create where Person == user || Conversation.Parties.Any(p => p.Person == user); allow update where Person == user; } } entity Line { [Required] Conversation Conversation; [Required] User Author; [Required, MaxLength(2000)] string Body; [Required] DateTime SentAt; security { allow read where Conversation.Parties.Any(p => p.Person == user); allow create where Conversation.Parties.Any(p => p.Person == user); } } class DirectLine { public string Body; public string Author; public DateTime SentAt; } topic DirectFeed(Guid conversationId) { Candidates = u => Party.Any(p => p.Conversation.Id == conversationId && p.Person == u); Carries = DirectLine; } topic DirectPresence(Guid conversationId) { Candidates = u => Party.Any(p => p.Conversation.Id == conversationId && p.Person == u); Carries = Seat; Presence = true; } Conversation OpenDirect(User other) { var me = Session.CurrentUser; if (other == me) { return null; } var visible = Party.Include(p => p.Conversation).ToList(); foreach (var theirs in visible.Where(p => p.Person == other)) { if (theirs.Conversation.Title == "" && visible.Where(p => p.Conversation == theirs.Conversation).Count() == 2) { return theirs.Conversation; } } var now = DateTime.UtcNow; var conversation = new Conversation { Title = "" }; new Party { Conversation = conversation, Person = me, LastReadAt = now }; new Party { Conversation = conversation, Person = other, LastReadAt = now }; 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; var sent = DateTime.UtcNow; new Line { Conversation = conversation, Author = me, Body = text, SentAt = sent }; DirectFeed.For(conversationId).Publish(new DirectLine { Body = text, Author = me.DisplayName, SentAt = sent }); var href = "/d/" + conversationId; foreach (var them in Party.Where(p => p.Conversation == conversation).Include(p => p.Person).ToList()) { if (them.Person != me) { Inbox.For(them.Person.Id).Publish(new Nudge { Kind = NudgeKind.Direct, From = me.DisplayName, Preview = text, Href = href, }); } } NudgeMentions(text, href, me); MarkConversationRead(conversationId); } void MarkConversationRead(Guid conversationId) { var me = Session.CurrentUser; var mine = Party.Where(p => p.Conversation.Id == conversationId && p.Person == me).FirstOrDefault(); if (mine == null) { return; } mine.LastReadAt = DateTime.UtcNow; }
model/identity.osy60 lines
// Ember's credentials. No special access is minted, and a forged ticket buys nothing (the server re-verifies every // request). [Role] enum AppRole { Authenticator, Member } enum StatusKind { Available, Busy, Away } entity RoleGrant { User Grantee; [Required] AppRole Level; security { } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); [Principal] entity User { [Unique, MaxLength(200)] string Email; [MaxLength(200)] string PasswordHash; [MaxLength(80)] string DisplayName = ""; [Required] StatusKind Status = StatusKind.Available; [MaxLength(80)] string StatusMessage = ""; security { allow read when IsAuthenticator; allow create when IsAuthenticator; allow update when IsAuthenticator; allow read when IsAuthenticated; 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 ""; } app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, };
model/inbox.osy31 lines
// THE INBOX — the piece that makes everything else reach somebody who is not already looking at it. 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, User 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.osy30 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) { var me = Session.CurrentUser; 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.osy94 lines
// EMBER'S WORKSPACE — channels, membership, posts, and the two topics. entity Channel { [Required, MaxLength(60)] string Name; [MaxLength(160)] string Purpose = ""; security { allow read, create when IsAuthenticated; } } [Unique(Channel, Person)] entity Membership { [Required] Channel Channel; [Required] User Person; [Required] DateTime LastReadAt; security { allow read where Person == user; allow create where Person == user; allow update where Person == user; } } entity Post { [Required] Channel Channel; [Required] User Author; [Required, MaxLength(2000)] string Body; [Required] 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 User 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; 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.osy188 lines
using Osyrin.Ui; [Page("/c/{channelId}")] [Layout(EmberShell)] [Render(CSR)] 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).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(); } render { Stack(gap: 0, minH: "100vh", minW: "0") { Row(gap: Space.Beat, align: Align.Center, justify: Justify.SpaceBetween, 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.SpaceBetween, 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); } } } } } } [Page("/")] [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.osy151 lines
using Osyrin.Ui; [Page("/d/{conversationId}")] [Layout(EmberShell)] [Render(CSR)] component DirectPage(Guid conversationId) { var me = Session.CurrentUser; live var conversation = Conversation.Where(c => c.Id == conversationId).FirstOrDefault(); live var parties = Party.Where(p => p.Conversation.Id == conversationId).Include(p => p.Person).ToList(); live var people = User.OrderBy(u => u.DisplayName).ToList(); live var handles = people.Select(u => "@" + u.DisplayName).ToList(); var history = Line .Where(l => l.Conversation.Id == conversationId) .Include(l => l.Author) .OrderBy(l => l.SentAt) .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(); } 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.SpaceBetween, 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 parties.Where(p => p.Person != me)) { Row(gap: Space.Tick, align: Align.Center) { Avatar(initials: them.Person.DisplayName, size: Size.Sm); Text(them.Person.DisplayName, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold); if (them.Person.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.Person.Status == StatusKind.Busy ? Colors.FillDanger : Colors.FillMuted); Text(them.Person.Status == StatusKind.Busy ? "busy" : "away", fontSize: FontSize.Micro, fontFamily: Font.Mono, color: Colors.TextSecondary); if (them.Person.StatusMessage != "") { Text(them.Person.StatusMessage, fontSize: FontSize.Micro, fontFamily: Font.Mono); } } } } } if (conversation.Title != "") { Text(conversation.Title, 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); } } foreach (var l in history) { Row(gap: Space.Beat, align: Align.Start) { Avatar(initials: l.Author.DisplayName, size: Size.Sm); Stack(gap: "2px", minW: "0") { Row(gap: Space.Step, align: Align.Center) { Text(l.Author.DisplayName, 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); } } } 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.osy58 lines
using Osyrin.Ui; [Page("/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: "you@team.co", 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.osy40 lines
using Osyrin.Ui; 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.osy203 lines
using Osyrin.Ui; [Layout] component EmberShell() { var me = Session.CurrentUser; live var mine = Membership.Include(m => m.Channel).ToList(); live var visible = Post.Include(p => p.Channel).ToList(); live var parties = Party.Include(p => p.Person).Include(p => p.Conversation).ToList(); live var lines = Line.Include(l => l.Conversation).ToList(); live var people = User.OrderBy(u => u.DisplayName).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) { Row(gap: Space.Step, align: Align.Center, justify: Justify.SpaceBetween, px: Space.Step, py: Space.Tick, rounded: Radius.Control) { 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 parties.Where(p => p.Person.Id == me.Id)) { Link(href: "/d/" + mine.Conversation.Id, textDecoration: TextDecoration.None, color: Colors.OnBg) { Row(gap: Space.Step, align: Align.Center, justify: Justify.SpaceBetween, px: Space.Step, py: Space.Tick, rounded: Radius.Control) { Row(gap: Space.Tick, align: Align.Center, minW: "0") { if (mine.Conversation.Title == "") { foreach (var them in parties.Where(p => p.Conversation == mine.Conversation && p.Person.Id != me.Id)) { Text(them.Person.DisplayName, fontSize: FontSize.Body); } } if (mine.Conversation.Title != "") { Text(mine.Conversation.Title, fontSize: FontSize.Body); } } if (lines.Where(l => l.Conversation == mine.Conversation && l.SentAt > mine.LastReadAt).Count() > 0) { Box(bg: Colors.FillAccent, color: Colors.Surface1, rounded: Radius.Pill, px: Space.Step, py: "1px") { Text(lines.Where(l => l.Conversation == mine.Conversation && l.SentAt > mine.LastReadAt).Count(), fontSize: FontSize.Micro, fontWeight: FontWeight.Bold, fontFamily: Font.Mono); } } } } } } if (parties.Where(p => p.Person.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.SpaceBetween, 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); } } } } } }