chat-room
A group chat — conversations, participants and messages, behind a login.
5 source files1 test file

Get it
$ osy init chat-room $ osy launch
The app
app.osy8 lines
// A group chat — conversations, participants and messages, behind a login. // Originally the spike for M256: how much of a group chat already works with what shipped? app ChatRoom { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; }
model/auth.osy77 lines
// The smallest real login, because this spike needs TWO DISTINCT PRINCIPALS and they must be real ones. An // anonymous launch would make both viewers the same nobody, and the RLS half of the question — a non-participant is // refused the room's messages — cannot be asked at all without two identities the server can tell apart. using Osyrin.Ui; [Role] enum AppRole { Authenticator, Member } entity RoleGrant { User Grantee; [Required] AppRole Level; security { } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); [Principal] entity User { [MaxLength(200), Unique] string Email; [MaxLength(200)] string PasswordHash; security { deny read PasswordHash when !IsAuthenticator; allow read, create, update when IsAuthenticator; allow read when IsAuthenticated; } } [AuthMethod] string Signup(string email, string password) { var u = new User { Email = email, PasswordHash = Security.HashPassword(password) }; 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, }; [Page("/login")] [AllowAnonymous] [Render(CSR)] component LoginPage() { string email = "you@room.test"; string password = "demo-password"; string problem = ""; action DoSignup() { var ticket = Signup(email, password); if (ticket == "") { problem = "That email and password don't match anyone."; } else { Session.SignIn(ticket); } } render { Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) { Stack(gap: 4, w: "100%", maxW: "26rem") { Stack(gap: 1) { Text("The room", fontSize: FontSize.Title, fontWeight: FontWeight.Semibold); Hint("Conversations, participants and messages — behind a login."); } Card("Sign in") { Stack(gap: 3) { Field("Email", value: email, placeholder: "you@example.com", type: "email"); Field("Password", value: password, type: "password"); if (problem != "") { Hint(problem); } Button("Create account", onPress: DoSignup, tone: Tone.Primary); } } } } } }
model/chat.osy63 lines
// THE ROOM, AS THREE ENTITIES — and the security rule is the interesting line in the file. // One room. `Topic` is what a person calls it; `StartedAt` orders rooms once there is more than one. entity Conversation { [Required][MaxLength(120)] string Topic; [Required] DateTime StartedAt; security { allow read, create when IsAuthenticated; } } entity Participant { [Required] Conversation Conversation; [Required] User Person; [Required] DateTime JoinedAt; security { allow read where Participant.Any(p => p.Conversation == Conversation && p.Person == user); allow create where Person == user; } } entity Message { [Required] Conversation Conversation; [Required] User Author; [Required][MaxLength(2000)] string Body; [Required] DateTime SentAt; security { allow read where Participant.Any(p => p.Conversation == Conversation && p.Person == user); allow create where Author == user && Participant.Any(p => p.Conversation == Conversation && p.Person == user); } } /// Make sure there is a room, and that the caller is in it. Returns the room's id, which is what the page's live /// query filters on. Guid JoinTheRoom() { var room = Conversation.OrderBy(c => c.StartedAt).FirstOrDefault(); if (room == null) { room = new Conversation { Topic = "The room", StartedAt = DateTime.UtcNow }; } var me = Session.CurrentUser; if (!Participant.Any(p => p.Conversation == room && p.Person == me)) { new Participant { Conversation = room, Person = me, JoinedAt = DateTime.UtcNow }; } UnitOfWork.Commit(); return room.Id; } /// Post a message to a room you are in. void Say(Guid roomId, string body) { if (body.Trim() == "") { return; } var room = Conversation.Where(c => c.Id == roomId).FirstOrDefault(); if (room == null) { throw new Exception("No such room."); } new Message { Conversation = room, Author = Session.CurrentUser, Body = body.Trim(), SentAt = DateTime.UtcNow, }; UnitOfWork.Commit(); }
model/room.osy48 lines
// THE PAGE — and it is small on purpose. Everything interesting about this spike is in what the page does NOT say. [Page("/")] [Render(CSR)] [Title("The room")] component Room() { Guid roomId = Guid.Empty; string draft = ""; live var messages = Message .Where(m => m.Conversation.Id == roomId) .Include(m => m.Author) .OrderBy(m => m.SentAt) .ToList(); on mount { roomId = JoinTheRoom(); } action Send() { Say(roomId, draft); draft = ""; } render { Stack(gap: 4, p: 6, maxW: "720px", bg: Colors.Bg, color: Colors.OnBg, minH: "100vh") { Text("The room", fontSize: FontSize.Hero, fontWeight: FontWeight.Medium); // The viewer's scalar fields ride the boot bag, so this is CLIENT state and costs no query. // Binding the whole entity (`var me = Session.CurrentUser;`) would have been a server read — // the row under this app's security — to render one string the browser already had. Text("Signed in as " + Session.CurrentUser.Email, fontSize: FontSize.Caption, color: Colors.TextMuted); Stack(gap: 2) { foreach (var m in messages) { Row(gap: 2) { Text(m.Author.Email + ":", fontWeight: FontWeight.Medium); Text(m.Body); } } } Row(gap: 2) { Input(value: draft, label: "Message", placeholder: "say something"); Osyrin.Button("Send", onClick: Send); } } } }
model/theme.osy14 lines
// chat-room — ITS OWN LOOK: a warm near-greyscale and a larger reading size, because a room of // messages is read, not scanned. Deliberately NOT the shared demo theme; see `DemoSharedThemeTests`. theme Room { Colors { Bg = Modes.Of(light: "#FBFBFA", dark: "#1A1A19"); OnBg = Modes.Of(light: "#1E1E1C", dark: "#ECEAE6"); TextMuted = Modes.Of(light: "#6C6A64", dark: "#A6A29A"); } FontSize { Caption = "12px"; Body = "15px"; Hero = "20px"; } FontWeight { Regular = 400; Medium = 500; } }