feat: implement client backend
This includes temporary AI-generated frontend scaffolding, none of this will be used in production. It was just used during development for the backend.
@@ -0,0 +1,26 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "../theme.slint";
|
||||
|
||||
// Coloured circle with an initial. Real avatar images (mxc:// media) are a
|
||||
// planned follow-up; this is also the fallback when a user has no avatar.
|
||||
export component Avatar inherits Rectangle {
|
||||
in property <string> initial;
|
||||
in property <color> tint: Theme.accent;
|
||||
in property <length> diameter: 40px;
|
||||
|
||||
width: diameter;
|
||||
height: diameter;
|
||||
border-radius: diameter / 2;
|
||||
background: tint;
|
||||
|
||||
Text {
|
||||
text: root.initial;
|
||||
color: white;
|
||||
font-size: root.diameter * 0.42;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "../theme.slint";
|
||||
|
||||
// Red mention counter ("3", "99+").
|
||||
export component Badge inherits Rectangle {
|
||||
in property <int> count;
|
||||
|
||||
visible: count > 0;
|
||||
height: 16px;
|
||||
width: max(16px, label.preferred-width + 10px);
|
||||
border-radius: 8px;
|
||||
background: Theme.danger;
|
||||
|
||||
label := Text {
|
||||
text: root.count > 99 ? "99+" : "\{root.count}";
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { ListView } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { Icons } from "../icons.slint";
|
||||
import { ChannelItem, ChannelKind } from "../types.slint";
|
||||
import { Icon } from "icon.slint";
|
||||
import { Avatar } from "avatar.slint";
|
||||
import { Badge } from "badge.slint";
|
||||
|
||||
component ChannelRow inherits Rectangle {
|
||||
in property <ChannelItem> c;
|
||||
in property <bool> selected;
|
||||
callback clicked();
|
||||
|
||||
height: c.kind == ChannelKind.category ? 34px : (c.is-dm ? 44px : 34px);
|
||||
opacity: c.joined || c.kind == ChannelKind.category ? 1.0 : 0.5;
|
||||
|
||||
// --- category heading ("TEXT CHANNELS") ---
|
||||
if c.kind == ChannelKind.category: Text {
|
||||
x: 16px;
|
||||
y: 0px;
|
||||
width: parent.width - 32px;
|
||||
height: parent.height - 4px;
|
||||
text: c.name;
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
vertical-alignment: bottom;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// --- text / voice channel ---
|
||||
if c.kind != ChannelKind.category && !c.is-dm: Rectangle {
|
||||
x: 8px;
|
||||
y: 1px;
|
||||
width: parent.width - 16px;
|
||||
height: parent.height - 2px;
|
||||
border-radius: 4px;
|
||||
background: root.selected ? Theme.selected-bg : (touch.has-hover ? Theme.hover-bg : transparent);
|
||||
|
||||
Icon {
|
||||
source: c.kind == ChannelKind.voice ? Icons.speaker : Icons.hash;
|
||||
tint: root.selected || c.unread ? Theme.text-header : Theme.text-muted;
|
||||
icon-size: 18px;
|
||||
x: 8px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 34px;
|
||||
width: parent.width - 34px - (c.mentions > 0 ? 40px : 8px);
|
||||
height: parent.height;
|
||||
text: c.name;
|
||||
color: root.selected || c.unread || touch.has-hover ? Theme.text-header : Theme.text-muted;
|
||||
font-weight: c.unread ? 700 : 500;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
Badge {
|
||||
count: c.mentions;
|
||||
x: parent.width - self.width - 8px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- direct message ---
|
||||
if c.is-dm && c.kind != ChannelKind.category: Rectangle {
|
||||
x: 8px;
|
||||
y: 1px;
|
||||
width: parent.width - 16px;
|
||||
height: parent.height - 2px;
|
||||
border-radius: 4px;
|
||||
background: root.selected ? Theme.selected-bg : (touch.has-hover ? Theme.hover-bg : transparent);
|
||||
|
||||
Avatar {
|
||||
initial: c.initial;
|
||||
tint: c.color;
|
||||
diameter: 32px;
|
||||
x: 8px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 50px;
|
||||
width: parent.width - 50px - (c.mentions > 0 ? 40px : 8px);
|
||||
height: parent.height;
|
||||
text: c.name;
|
||||
color: root.selected || c.unread || touch.has-hover ? Theme.text-header : Theme.text-muted;
|
||||
font-weight: c.unread ? 700 : 500;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
Badge {
|
||||
count: c.mentions;
|
||||
x: parent.width - self.width - 8px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
enabled: c.kind != ChannelKind.category;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component ChannelList inherits Rectangle {
|
||||
in property <string> title;
|
||||
in property <bool> is-home;
|
||||
in property <[ChannelItem]> channels;
|
||||
in property <string> selected-id;
|
||||
callback select(string);
|
||||
callback open-server-menu();
|
||||
|
||||
width: Theme.sidebar-width;
|
||||
background: Theme.sidebar-bg;
|
||||
|
||||
VerticalLayout {
|
||||
// Header: server name (or "Direct Messages")
|
||||
Rectangle {
|
||||
height: Theme.header-height;
|
||||
background: header-touch.has-hover && !root.is-home ? Theme.hover-bg : transparent;
|
||||
|
||||
Text {
|
||||
x: 16px;
|
||||
width: parent.width - 52px;
|
||||
height: parent.height;
|
||||
text: root.title;
|
||||
color: Theme.text-header;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
if !root.is-home: Icon {
|
||||
source: Icons.chevron-down;
|
||||
tint: Theme.text-header;
|
||||
icon-size: 16px;
|
||||
x: parent.width - 16px - self.width;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
|
||||
// bottom hairline
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: Theme.rail-bg;
|
||||
}
|
||||
|
||||
header-touch := TouchArea {
|
||||
enabled: !root.is-home;
|
||||
clicked => {
|
||||
root.open-server-menu();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
for c in root.channels: ChannelRow {
|
||||
c: c;
|
||||
selected: c.id == root.selected-id;
|
||||
clicked => {
|
||||
root.select(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "../theme.slint";
|
||||
import { Icons } from "../icons.slint";
|
||||
import { Icon } from "icon.slint";
|
||||
import { ToolButton } from "tool-button.slint";
|
||||
|
||||
// ToolButton has a fixed height, so wrap it to centre it in the 48px header.
|
||||
component HeaderTool inherits Rectangle {
|
||||
in property <image> glyph;
|
||||
in property <bool> active;
|
||||
callback clicked();
|
||||
width: 32px;
|
||||
ToolButton {
|
||||
glyph: root.glyph;
|
||||
active: root.active;
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component ChatHeader inherits Rectangle {
|
||||
in property <string> room-name;
|
||||
in property <string> topic;
|
||||
in property <bool> is-dm;
|
||||
in property <bool> encrypted;
|
||||
in-out property <bool> show-members;
|
||||
callback open-threads();
|
||||
callback open-pins();
|
||||
callback open-search();
|
||||
|
||||
height: Theme.header-height;
|
||||
background: Theme.chat-bg;
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: Theme.rail-bg;
|
||||
}
|
||||
|
||||
// Left: "# name | topic"
|
||||
HorizontalLayout {
|
||||
padding-left: 16px;
|
||||
padding-right: 8px;
|
||||
spacing: 8px;
|
||||
|
||||
Rectangle {
|
||||
width: 22px;
|
||||
if !root.is-dm: Icon {
|
||||
source: Icons.hash;
|
||||
tint: Theme.text-muted;
|
||||
icon-size: 22px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
if root.is-dm: Text {
|
||||
text: "@";
|
||||
color: Theme.text-muted;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.room-name;
|
||||
color: Theme.text-header;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
width: min(self.preferred-width, 280px);
|
||||
}
|
||||
|
||||
if root.encrypted: Rectangle {
|
||||
width: 16px;
|
||||
Icon {
|
||||
source: Icons.lock;
|
||||
tint: Theme.text-muted;
|
||||
icon-size: 14px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if root.topic != "": Rectangle {
|
||||
width: 1px;
|
||||
Rectangle {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.divider;
|
||||
}
|
||||
}
|
||||
|
||||
if root.topic != "": Text {
|
||||
text: root.topic;
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 0px;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
// Right: toolbar. These open panels that aren't built yet (see docs/DISCORD-PARITY.md).
|
||||
HeaderTool {
|
||||
glyph: Icons.thread;
|
||||
clicked => {
|
||||
root.open-threads();
|
||||
}
|
||||
}
|
||||
|
||||
HeaderTool {
|
||||
glyph: Icons.pin;
|
||||
clicked => {
|
||||
root.open-pins();
|
||||
}
|
||||
}
|
||||
|
||||
HeaderTool {
|
||||
glyph: Icons.users;
|
||||
active: root.show-members;
|
||||
clicked => {
|
||||
root.show-members = !root.show-members;
|
||||
}
|
||||
}
|
||||
|
||||
HeaderTool {
|
||||
glyph: Icons.search;
|
||||
clicked => {
|
||||
root.open-search();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "../theme.slint";
|
||||
import { Icons } from "../icons.slint";
|
||||
import { Icon } from "icon.slint";
|
||||
import { ToolButton } from "tool-button.slint";
|
||||
|
||||
// Message box. Enter sends, Shift+Enter inserts a newline (same as Discord).
|
||||
// The component is deliberately stateless about *what* is being sent: the
|
||||
// parent owns the reply/edit state and just tells us what banner to show.
|
||||
export component Composer inherits Rectangle {
|
||||
in property <string> placeholder;
|
||||
in property <string> banner; // "Replying to Alice" / "Editing message"; "" hides it
|
||||
in property <bool> editing;
|
||||
in property <string> typing-text; // "Bob is typing…"
|
||||
callback submit(string);
|
||||
callback cancel-banner();
|
||||
callback clear-text();
|
||||
callback typing();
|
||||
callback attach();
|
||||
// Parent -> composer requests, as properties so the composer can live inside an `if`.
|
||||
in property <string> text-to-set;
|
||||
in property <int> text-nonce; // bump to replace the input text with `text-to-set` (and focus)
|
||||
in property <int> focus-nonce; // bump to just focus the input
|
||||
changed text-nonce => {
|
||||
input.text = root.text-to-set;
|
||||
input.focus();
|
||||
}
|
||||
changed focus-nonce => {
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// Grows with content up to ~8 lines, like Discord.
|
||||
property <length> input-height: min(max(input.preferred-height, 24px), 180px);
|
||||
|
||||
height: (root.banner != "" ? 32px : 0px) + input-height + 24px + 24px;
|
||||
background: Theme.chat-bg;
|
||||
|
||||
// Reply / edit banner sits above the input and shares its rounded top.
|
||||
if root.banner != "": Rectangle {
|
||||
x: 16px;
|
||||
y: 0px;
|
||||
width: parent.width - 32px;
|
||||
height: 32px + 8px;
|
||||
background: Theme.sidebar-bg;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
|
||||
Text {
|
||||
x: 12px;
|
||||
width: parent.width - 60px;
|
||||
height: 32px;
|
||||
text: root.banner;
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
glyph: Icons.close;
|
||||
x: parent.width - self.width - 6px;
|
||||
y: 2px;
|
||||
clicked => {
|
||||
root.cancel-banner();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
box := Rectangle {
|
||||
x: 16px;
|
||||
y: (root.banner != "" ? 32px : 0px);
|
||||
width: parent.width - 32px;
|
||||
height: root.input-height + 24px;
|
||||
background: Theme.input-bg;
|
||||
border-radius: root.banner != "" ? 0px : 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
|
||||
ToolButton {
|
||||
glyph: Icons.plus-circle;
|
||||
x: 6px;
|
||||
y: parent.height - self.height - 10px;
|
||||
clicked => {
|
||||
root.attach();
|
||||
}
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
x: 44px;
|
||||
y: 12px;
|
||||
width: parent.width - 44px - 52px;
|
||||
height: root.input-height;
|
||||
|
||||
// Intercept Enter before the TextInput turns it into a newline.
|
||||
capture-key-pressed(event) => {
|
||||
if (event.text == Key.Return && !event.modifiers.shift) {
|
||||
if (input.text != "") {
|
||||
root.submit(input.text);
|
||||
input.text = "";
|
||||
}
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
|
||||
input := TextInput {
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
single-line: false;
|
||||
wrap: word-wrap;
|
||||
color: Theme.text-normal;
|
||||
font-size: 15px;
|
||||
selection-background-color: Theme.accent;
|
||||
edited => {
|
||||
root.typing();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if input.text == "": Text {
|
||||
x: 44px;
|
||||
y: 12px;
|
||||
width: parent.width - 100px;
|
||||
height: 24px;
|
||||
text: root.placeholder;
|
||||
color: Theme.text-faint;
|
||||
font-size: 15px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
glyph: Icons.smile;
|
||||
x: parent.width - self.width - 8px;
|
||||
y: parent.height - self.height - 10px;
|
||||
// TODO: emoji picker
|
||||
}
|
||||
}
|
||||
|
||||
// Typing indicator, under the box.
|
||||
Text {
|
||||
x: 20px;
|
||||
y: parent.height - 22px;
|
||||
width: parent.width - 40px;
|
||||
height: 18px;
|
||||
text: root.typing-text;
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "../theme.slint";
|
||||
|
||||
// A recolourable icon. Usage: `Icon { source: Icons.hash; tint: Theme.text-muted; }`
|
||||
export component Icon inherits Image {
|
||||
in property <color> tint: Theme.text-muted;
|
||||
in property <length> icon-size: 20px;
|
||||
width: icon-size;
|
||||
height: icon-size;
|
||||
colorize: tint;
|
||||
image-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { ListView } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { MemberItem } from "../types.slint";
|
||||
import { Avatar } from "avatar.slint";
|
||||
|
||||
component MemberRow inherits Rectangle {
|
||||
in property <MemberItem> m;
|
||||
height: m.is-header ? 36px : 42px;
|
||||
|
||||
if m.is-header: Text {
|
||||
x: 16px;
|
||||
width: parent.width - 32px;
|
||||
height: parent.height - 4px;
|
||||
text: m.name;
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
vertical-alignment: bottom;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
if !m.is-header: Rectangle {
|
||||
x: 8px;
|
||||
y: 1px;
|
||||
width: parent.width - 16px;
|
||||
height: parent.height - 2px;
|
||||
border-radius: 4px;
|
||||
background: touch.has-hover ? Theme.hover-bg : transparent;
|
||||
|
||||
Avatar {
|
||||
initial: m.initial;
|
||||
tint: m.color;
|
||||
diameter: 32px;
|
||||
x: 8px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 50px;
|
||||
width: parent.width - 58px;
|
||||
height: parent.height;
|
||||
text: m.name;
|
||||
color: touch.has-hover ? Theme.text-header : Theme.text-muted;
|
||||
font-weight: 500;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
touch := TouchArea { }
|
||||
}
|
||||
}
|
||||
|
||||
export component MemberList inherits Rectangle {
|
||||
in property <[MemberItem]> members;
|
||||
|
||||
width: Theme.member-width;
|
||||
background: Theme.sidebar-bg;
|
||||
|
||||
ListView {
|
||||
for m in root.members: MemberRow {
|
||||
m: m;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { ListView } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { Icons } from "../icons.slint";
|
||||
import { MessageItem, MessageKind, ReactionItem } from "../types.slint";
|
||||
import { Icon } from "icon.slint";
|
||||
import { Avatar } from "avatar.slint";
|
||||
import { ToolButton } from "tool-button.slint";
|
||||
|
||||
component ReactionChip inherits Rectangle {
|
||||
in property <ReactionItem> r;
|
||||
callback clicked();
|
||||
|
||||
height: 24px;
|
||||
width: label.preferred-width + 20px;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-color: r.mine ? Theme.accent : Theme.sidebar-bg;
|
||||
background: r.mine ? Theme.accent.with-alpha(0.22) : Theme.sidebar-bg;
|
||||
|
||||
label := Text {
|
||||
text: "\{r.key} \{r.count}";
|
||||
font-size: 13px;
|
||||
color: r.mine ? Theme.text-header : Theme.text-muted;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Floating toolbar shown on message hover (react / reply / edit / delete).
|
||||
component HoverBar inherits Rectangle {
|
||||
in property <bool> own;
|
||||
callback react();
|
||||
callback reply();
|
||||
callback edit();
|
||||
callback delete();
|
||||
|
||||
height: 32px;
|
||||
width: (own ? 4 : 2) * 32px + 8px;
|
||||
background: Theme.chat-bg;
|
||||
border-radius: 6px;
|
||||
border-width: 1px;
|
||||
border-color: Theme.divider;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 2px;
|
||||
spacing: 0px;
|
||||
ToolButton {
|
||||
glyph: Icons.smile;
|
||||
clicked => {
|
||||
root.react();
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
glyph: Icons.reply;
|
||||
clicked => {
|
||||
root.reply();
|
||||
}
|
||||
}
|
||||
|
||||
if root.own: ToolButton {
|
||||
glyph: Icons.edit;
|
||||
clicked => {
|
||||
root.edit();
|
||||
}
|
||||
}
|
||||
|
||||
if root.own: ToolButton {
|
||||
glyph: Icons.trash;
|
||||
tint: Theme.danger;
|
||||
clicked => {
|
||||
root.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component ChatMessage inherits Rectangle {
|
||||
in property <MessageItem> m;
|
||||
callback reply();
|
||||
callback edit();
|
||||
callback delete();
|
||||
callback react(string);
|
||||
|
||||
background: hover.has-hover ? Theme.message-hover-bg : transparent;
|
||||
|
||||
// The TouchArea *wraps* the content so hover stays true while the pointer
|
||||
// is over children (text, chips, the toolbar).
|
||||
hover := TouchArea {
|
||||
VerticalLayout {
|
||||
padding-top: root.m.show-header ? 14px : 1px;
|
||||
padding-bottom: 2px;
|
||||
spacing: 2px;
|
||||
|
||||
if root.m.has-reply: Rectangle {
|
||||
height: 20px;
|
||||
Text {
|
||||
x: Theme.gutter;
|
||||
width: parent.width - Theme.gutter - 16px;
|
||||
height: parent.height;
|
||||
text: "↳ \{root.m.reply-sender} \{root.m.reply-body}";
|
||||
color: Theme.text-muted;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
spacing: 16px;
|
||||
|
||||
// Gutter: avatar on the first message of a group, hover-time otherwise.
|
||||
Rectangle {
|
||||
width: 40px;
|
||||
if root.m.show-header: Avatar {
|
||||
initial: root.m.initial;
|
||||
tint: root.m.color;
|
||||
diameter: 40px;
|
||||
y: 2px;
|
||||
}
|
||||
if !root.m.show-header: Text {
|
||||
visible: hover.has-hover;
|
||||
text: root.m.short-time;
|
||||
color: Theme.text-faint;
|
||||
font-size: 10px;
|
||||
horizontal-alignment: center;
|
||||
y: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 2px;
|
||||
|
||||
if root.m.show-header: HorizontalLayout {
|
||||
height: 20px;
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
Text {
|
||||
text: root.m.sender;
|
||||
color: root.m.color.brighter(0.6);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.m.time;
|
||||
color: Theme.text-faint;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only TextInput rather than Text: lets people select + copy.
|
||||
TextInput {
|
||||
text: root.m.body;
|
||||
read-only: true;
|
||||
single-line: false;
|
||||
wrap: word-wrap;
|
||||
color: root.m.pending ? Theme.text-muted : Theme.text-normal;
|
||||
font-size: 15px;
|
||||
selection-background-color: Theme.accent;
|
||||
}
|
||||
|
||||
if root.m.edited: Text {
|
||||
text: "(edited)";
|
||||
color: Theme.text-faint;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
if root.m.reactions.length > 0: HorizontalLayout {
|
||||
height: 28px;
|
||||
padding-top: 4px;
|
||||
spacing: 4px;
|
||||
alignment: start;
|
||||
for r in root.m.reactions: ReactionChip {
|
||||
r: r;
|
||||
clicked => {
|
||||
root.react(r.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Absolutely positioned => excluded from the row's height calculation.
|
||||
if hover.has-hover && !root.m.pending: HoverBar {
|
||||
own: root.m.own;
|
||||
x: parent.width - self.width - 16px;
|
||||
y: -14px;
|
||||
react => {
|
||||
root.react("👍");
|
||||
}
|
||||
reply => {
|
||||
root.reply();
|
||||
}
|
||||
edit => {
|
||||
root.edit();
|
||||
}
|
||||
delete => {
|
||||
root.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component DateDivider inherits Rectangle {
|
||||
in property <string> text;
|
||||
height: 40px;
|
||||
|
||||
Rectangle {
|
||||
x: 16px;
|
||||
width: parent.width - 32px;
|
||||
y: parent.height / 2;
|
||||
height: 1px;
|
||||
background: Theme.divider;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: label.preferred-width + 16px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.chat-bg;
|
||||
label := Text {
|
||||
text: root.text;
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component NoticeRow inherits Rectangle {
|
||||
in property <string> text;
|
||||
height: 28px;
|
||||
Text {
|
||||
x: Theme.gutter;
|
||||
width: parent.width - Theme.gutter - 16px;
|
||||
height: parent.height;
|
||||
text: root.text;
|
||||
color: Theme.text-faint;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
component RoomStart inherits Rectangle {
|
||||
in property <string> room-name;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
padding-top: 32px;
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: "Welcome to #\{root.room-name}!";
|
||||
color: Theme.text-header;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "This is the start of the #\{root.room-name} channel.";
|
||||
color: Theme.text-muted;
|
||||
font-size: 15px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component RowSwitch inherits Rectangle {
|
||||
in property <MessageItem> m;
|
||||
in property <string> room-name;
|
||||
callback reply();
|
||||
callback edit();
|
||||
callback delete();
|
||||
callback react(string);
|
||||
|
||||
VerticalLayout {
|
||||
if root.m.kind == MessageKind.message: ChatMessage {
|
||||
m: root.m;
|
||||
reply => {
|
||||
root.reply();
|
||||
}
|
||||
edit => {
|
||||
root.edit();
|
||||
}
|
||||
delete => {
|
||||
root.delete();
|
||||
}
|
||||
react(key) => {
|
||||
root.react(key);
|
||||
}
|
||||
}
|
||||
if root.m.kind == MessageKind.date-divider: DateDivider {
|
||||
text: root.m.body;
|
||||
}
|
||||
if root.m.kind == MessageKind.notice: NoticeRow {
|
||||
text: root.m.body;
|
||||
}
|
||||
if root.m.kind == MessageKind.start-of-room: RoomStart {
|
||||
room-name: root.room-name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component MessageList inherits Rectangle {
|
||||
in property <[MessageItem]> messages;
|
||||
in property <string> room-name;
|
||||
in property <bool> loading-older;
|
||||
in property <int> room-nonce; // bumped by Rust whenever a different room is opened
|
||||
callback reply(string, string); // id, sender
|
||||
callback edit(string, string); // id, body
|
||||
callback delete(string);
|
||||
callback react(string, string); // id, key
|
||||
callback load-older();
|
||||
|
||||
// "Stick to bottom": follow new messages unless the user scrolled up.
|
||||
property <bool> stick: true;
|
||||
property <length> last-height: 0px;
|
||||
pure function bottom-y(h: length, v: length) -> length {
|
||||
return min(0px, v - h);
|
||||
}
|
||||
|
||||
changed room-nonce => {
|
||||
root.stick = true;
|
||||
}
|
||||
|
||||
background: Theme.chat-bg;
|
||||
|
||||
list := ListView {
|
||||
height: parent.height - 8px; // small gap above the composer
|
||||
changed content-height => {
|
||||
if (root.stick) {
|
||||
self.content-y = bottom-y(self.content-height, self.visible-height);
|
||||
} else if (self.content-y > -40px && root.last-height > 0px) {
|
||||
// Older messages were prepended: keep what the user was reading in place.
|
||||
self.content-y = self.content-y - (self.content-height - root.last-height);
|
||||
}
|
||||
root.last-height = self.content-height;
|
||||
}
|
||||
changed content-y => {
|
||||
root.stick = self.content-y <= bottom-y(self.content-height, self.visible-height) + 24px;
|
||||
if (self.content-y > -24px && self.content-height > self.visible-height && !root.loading-older) {
|
||||
root.load-older();
|
||||
}
|
||||
}
|
||||
|
||||
for m in root.messages: RowSwitch {
|
||||
m: m;
|
||||
room-name: root.room-name;
|
||||
reply => {
|
||||
root.reply(m.id, m.sender);
|
||||
}
|
||||
edit => {
|
||||
root.edit(m.id, m.body);
|
||||
}
|
||||
delete => {
|
||||
root.delete(m.id);
|
||||
}
|
||||
react(key) => {
|
||||
root.react(m.id, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.loading-older: Rectangle {
|
||||
y: 8px;
|
||||
width: 140px;
|
||||
height: 24px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
border-radius: 12px;
|
||||
background: Theme.popup-bg;
|
||||
Text {
|
||||
text: "Loading history…";
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { Icons } from "../icons.slint";
|
||||
import { ServerItem } from "../types.slint";
|
||||
import { Icon } from "icon.slint";
|
||||
import { Badge } from "badge.slint";
|
||||
|
||||
// One round button on the rail. Morphs from circle to rounded square on hover
|
||||
// or selection, with a white "pill" on the left edge.
|
||||
component RailButton inherits Rectangle {
|
||||
in property <bool> selected;
|
||||
in property <bool> unread;
|
||||
in property <int> mentions;
|
||||
in property <color> idle-color: Theme.chat-bg;
|
||||
in property <color> active-color: Theme.accent;
|
||||
callback clicked();
|
||||
|
||||
height: 56px;
|
||||
|
||||
// Left-edge pill: tall = selected, medium = hovered, dot = unread.
|
||||
Rectangle {
|
||||
x: 0px;
|
||||
width: 4px;
|
||||
height: root.selected ? 40px : (touch.has-hover ? 20px : (root.unread ? 8px : 0px));
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background: Theme.text-header;
|
||||
animate height { duration: 120ms; }
|
||||
}
|
||||
|
||||
body := Rectangle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
clip: true;
|
||||
border-radius: root.selected || touch.has-hover ? 16px : 24px;
|
||||
background: root.selected || touch.has-hover ? root.active-color : root.idle-color;
|
||||
animate border-radius { duration: 120ms; }
|
||||
@children
|
||||
}
|
||||
|
||||
Badge {
|
||||
count: root.mentions;
|
||||
x: body.x + body.width - self.width + 4px;
|
||||
y: body.y + body.height - self.height + 4px;
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component ServerRail inherits Rectangle {
|
||||
in property <[ServerItem]> servers;
|
||||
in property <string> selected-id; // "" = Home (direct messages)
|
||||
in property <bool> home-unread;
|
||||
in property <int> home-mentions;
|
||||
callback select(string);
|
||||
callback add-server();
|
||||
|
||||
width: Theme.rail-width;
|
||||
background: Theme.rail-bg;
|
||||
|
||||
ScrollView {
|
||||
vertical-scrollbar-policy: always-off;
|
||||
horizontal-scrollbar-policy: always-off;
|
||||
|
||||
VerticalLayout {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
alignment: start;
|
||||
|
||||
RailButton {
|
||||
selected: root.selected-id == "";
|
||||
unread: root.home-unread;
|
||||
mentions: root.home-mentions;
|
||||
clicked => {
|
||||
root.select("");
|
||||
}
|
||||
Icon {
|
||||
source: Icons.chat;
|
||||
tint: white;
|
||||
icon-size: 26px;
|
||||
// `parent` here is the rail layout (children are projected into RailButton),
|
||||
// so centre against the button's fixed 48px body instead.
|
||||
x: (48px - self.width) / 2;
|
||||
y: (48px - self.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// separator
|
||||
Rectangle {
|
||||
height: 10px;
|
||||
Rectangle {
|
||||
width: 32px;
|
||||
height: 2px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: 4px;
|
||||
border-radius: 1px;
|
||||
background: Theme.divider;
|
||||
}
|
||||
}
|
||||
|
||||
for s in root.servers: RailButton {
|
||||
selected: root.selected-id == s.id;
|
||||
unread: s.unread;
|
||||
mentions: s.mentions;
|
||||
idle-color: s.color;
|
||||
active-color: s.color;
|
||||
clicked => {
|
||||
root.select(s.id);
|
||||
}
|
||||
Text {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
text: s.initial;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
RailButton {
|
||||
idle-color: Theme.chat-bg;
|
||||
active-color: Theme.success;
|
||||
clicked => {
|
||||
root.add-server();
|
||||
}
|
||||
Icon {
|
||||
source: Icons.plus;
|
||||
tint: Theme.success;
|
||||
icon-size: 24px;
|
||||
x: (48px - self.width) / 2;
|
||||
y: (48px - self.height) / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "../theme.slint";
|
||||
|
||||
// Single-line text field with placeholder, built on the TextInput primitive so
|
||||
// it matches the theme instead of the std-widgets style.
|
||||
export component TextField inherits Rectangle {
|
||||
in property <string> placeholder;
|
||||
in property <bool> password;
|
||||
in-out property <string> text <=> input.text;
|
||||
callback accepted();
|
||||
public function focus-field() {
|
||||
input.focus();
|
||||
}
|
||||
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background: Theme.rail-bg;
|
||||
border-width: 1px;
|
||||
border-color: input.has-focus ? Theme.accent : Theme.divider;
|
||||
|
||||
input := TextInput {
|
||||
x: 12px;
|
||||
y: 0px;
|
||||
width: parent.width - 24px;
|
||||
height: parent.height;
|
||||
vertical-alignment: center;
|
||||
color: Theme.text-normal;
|
||||
font-size: 15px;
|
||||
single-line: true;
|
||||
input-type: root.password ? InputType.password : InputType.text;
|
||||
selection-background-color: Theme.accent;
|
||||
accepted => {
|
||||
root.accepted();
|
||||
}
|
||||
}
|
||||
|
||||
if input.text == "": Text {
|
||||
x: 12px;
|
||||
width: parent.width - 24px;
|
||||
height: parent.height;
|
||||
text: root.placeholder;
|
||||
color: Theme.text-faint;
|
||||
font-size: 15px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "../theme.slint";
|
||||
import { Icon } from "icon.slint";
|
||||
|
||||
// Small square icon button used in headers and hover toolbars.
|
||||
export component ToolButton inherits Rectangle {
|
||||
in property <image> glyph;
|
||||
in property <color> tint: Theme.text-muted;
|
||||
in property <bool> active: false;
|
||||
callback clicked();
|
||||
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: touch.has-hover ? Theme.selected-bg : transparent;
|
||||
|
||||
Icon {
|
||||
source: root.glyph;
|
||||
icon-size: 18px;
|
||||
tint: root.active || touch.has-hover ? Theme.text-header : root.tint;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "../theme.slint";
|
||||
import { Icons } from "../icons.slint";
|
||||
import { Icon } from "icon.slint";
|
||||
import { Avatar } from "avatar.slint";
|
||||
import { ToolButton } from "tool-button.slint";
|
||||
|
||||
// Bottom-left "you" bar: avatar, name, mute / deafen / settings.
|
||||
export component UserPanel inherits Rectangle {
|
||||
in property <string> name;
|
||||
in property <string> user-id;
|
||||
in property <string> initial;
|
||||
in property <color> tint;
|
||||
in-out property <bool> muted;
|
||||
in-out property <bool> deafened;
|
||||
callback open-settings();
|
||||
callback logout();
|
||||
|
||||
height: 52px;
|
||||
background: #232428;
|
||||
|
||||
Avatar {
|
||||
initial: root.initial;
|
||||
tint: root.tint;
|
||||
diameter: 32px;
|
||||
x: 8px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
|
||||
// Presence dot
|
||||
Rectangle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: Theme.success;
|
||||
border-width: 2px;
|
||||
border-color: #232428;
|
||||
x: 8px + 32px - 10px;
|
||||
y: (parent.height - 32px) / 2 + 32px - 10px;
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 48px;
|
||||
y: 10px;
|
||||
width: 100px;
|
||||
height: 16px;
|
||||
text: root.name;
|
||||
color: Theme.text-header;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
Text {
|
||||
x: 48px;
|
||||
y: 26px;
|
||||
width: 100px;
|
||||
height: 14px;
|
||||
text: root.user-id;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
x: parent.width - self.preferred-width - 4px;
|
||||
y: (parent.height - 28px) / 2;
|
||||
spacing: 0px;
|
||||
// Voice isn't wired yet: these only flip local state for now.
|
||||
ToolButton {
|
||||
glyph: Icons.mic;
|
||||
tint: root.muted ? Theme.danger : Theme.text-muted;
|
||||
clicked => {
|
||||
root.muted = !root.muted;
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
glyph: Icons.headphones;
|
||||
tint: root.deafened ? Theme.danger : Theme.text-muted;
|
||||
clicked => {
|
||||
root.deafened = !root.deafened;
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
glyph: Icons.logout;
|
||||
clicked => {
|
||||
root.logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
// Icon set: 24px stroke icons from the Feather set (MIT), drawn white so they
|
||||
// can be recoloured with `colorize` (see components/icon.slint).
|
||||
export global Icons {
|
||||
out property <image> hash: @image-url("icons/hash.svg");
|
||||
out property <image> speaker: @image-url("icons/speaker.svg");
|
||||
out property <image> plus: @image-url("icons/plus.svg");
|
||||
out property <image> close: @image-url("icons/close.svg");
|
||||
out property <image> chat: @image-url("icons/chat.svg");
|
||||
out property <image> users: @image-url("icons/users.svg");
|
||||
out property <image> mic: @image-url("icons/mic.svg");
|
||||
out property <image> headphones: @image-url("icons/headphones.svg");
|
||||
out property <image> search: @image-url("icons/search.svg");
|
||||
out property <image> pin: @image-url("icons/pin.svg");
|
||||
out property <image> thread: @image-url("icons/thread.svg");
|
||||
out property <image> reply: @image-url("icons/reply.svg");
|
||||
out property <image> edit: @image-url("icons/edit.svg");
|
||||
out property <image> trash: @image-url("icons/trash.svg");
|
||||
out property <image> smile: @image-url("icons/smile.svg");
|
||||
out property <image> plus-circle: @image-url("icons/plus-circle.svg");
|
||||
out property <image> chevron-down: @image-url("icons/chevron-down.svg");
|
||||
out property <image> chevron-right: @image-url("icons/chevron-right.svg");
|
||||
out property <image> logout: @image-url("icons/logout.svg");
|
||||
out property <image> sliders: @image-url("icons/sliders.svg");
|
||||
out property <image> lock: @image-url("icons/lock.svg");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
|
After Width: | Height: | Size: 337 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 290 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/></svg>
|
||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>
|
||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
|
||||
|
After Width: | Height: | Size: 338 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/></svg>
|
||||
|
After Width: | Height: | Size: 382 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 3h6l-1 7 3 3v2H7v-2l3-3z"/></svg>
|
||||
|
After Width: | Height: | Size: 324 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
|
||||
|
After Width: | Height: | Size: 323 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
|
After Width: | Height: | Size: 293 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>
|
||||
|
After Width: | Height: | Size: 327 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
|
After Width: | Height: | Size: 324 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></svg>
|
||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8z"/></svg>
|
||||
|
After Width: | Height: | Size: 448 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></svg>
|
||||
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1,3 @@
|
||||
<!-- TEMPORARY AI GENERATED FRONTEND -->
|
||||
<!-- NOT TO BE USED IN PRODUCTION -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
|
After Width: | Height: | Size: 410 B |
@@ -0,0 +1,132 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { TextField } from "components/text-field.slint";
|
||||
|
||||
export component LoginScreen inherits Rectangle {
|
||||
in property <string> error;
|
||||
in property <bool> busy;
|
||||
in-out property <string> homeserver;
|
||||
in-out property <string> username;
|
||||
callback submit(string, string, string); // homeserver, username, password
|
||||
|
||||
background: Theme.rail-bg;
|
||||
|
||||
function try-submit() {
|
||||
if (!root.busy && root.username != "" && password.text != "") {
|
||||
root.submit(root.homeserver, root.username, password.text);
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: min(480px, parent.width - 32px);
|
||||
height: card.preferred-height + 64px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.chat-bg;
|
||||
border-radius: 8px;
|
||||
|
||||
card := VerticalLayout {
|
||||
padding: 32px;
|
||||
spacing: 6px;
|
||||
|
||||
Text {
|
||||
text: "Welcome back!";
|
||||
color: Theme.text-header;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Sign in with your Matrix account";
|
||||
color: Theme.text-muted;
|
||||
font-size: 15px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "HOMESERVER";
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
TextField {
|
||||
placeholder: "matrix.org";
|
||||
text <=> root.homeserver;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "USERNAME";
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
TextField {
|
||||
placeholder: "user (or @user:matrix.org)";
|
||||
text <=> root.username;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "PASSWORD";
|
||||
color: Theme.text-muted;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
password := TextField {
|
||||
password: true;
|
||||
accepted => {
|
||||
root.try-submit();
|
||||
}
|
||||
}
|
||||
|
||||
if root.error != "": Text {
|
||||
text: root.error;
|
||||
color: Theme.danger;
|
||||
font-size: 13px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
border-radius: 4px;
|
||||
background: root.busy ? Theme.accent-hover : (login-touch.has-hover ? Theme.accent-hover : Theme.accent);
|
||||
Text {
|
||||
text: root.busy ? "Signing in…" : "Log In";
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
login-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.try-submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,256 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Screen, ServerItem, ChannelItem, MessageItem, MemberItem } from "types.slint";
|
||||
import { LoginScreen } from "login.slint";
|
||||
import { ServerRail } from "components/server-rail.slint";
|
||||
import { ChannelList } from "components/channel-list.slint";
|
||||
import { UserPanel } from "components/user-panel.slint";
|
||||
import { ChatHeader } from "components/chat-header.slint";
|
||||
import { MessageList } from "components/message-list.slint";
|
||||
import { Composer } from "components/composer.slint";
|
||||
import { MemberList } from "components/member-list.slint";
|
||||
|
||||
export { Screen, ServerItem, ChannelItem, MessageItem, MemberItem, ChannelKind, MessageKind, ReactionItem } from "types.slint";
|
||||
|
||||
export component MainWindow inherits Window {
|
||||
preferred-width: 854px;
|
||||
preferred-height: 480px;
|
||||
title: "Tofu";
|
||||
min-width: 900px;
|
||||
min-height: 520px;
|
||||
preferred-width: 1280px;
|
||||
preferred-height: 800px;
|
||||
background: Theme.chat-bg;
|
||||
default-font-size: 15px;
|
||||
|
||||
min-width: 640px;
|
||||
min-height: 360px;
|
||||
// ---- State pushed from Rust -------------------------------------------
|
||||
in property <Screen> screen: Screen.loading;
|
||||
in property <string> login-error;
|
||||
in property <bool> login-busy;
|
||||
in-out property <string> login-homeserver;
|
||||
in-out property <string> login-username;
|
||||
|
||||
Text {
|
||||
text: "Tofu Client";
|
||||
font-size: 48px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
in property <string> me-name;
|
||||
in property <string> me-id;
|
||||
in property <string> me-initial;
|
||||
in property <color> me-color: Theme.accent;
|
||||
|
||||
in property <[ServerItem]> servers;
|
||||
in property <bool> home-unread;
|
||||
in property <int> home-mentions;
|
||||
in property <[ChannelItem]> channels;
|
||||
in property <[MessageItem]> messages;
|
||||
in property <[MemberItem]> members;
|
||||
|
||||
in property <string> sidebar-title: "Direct Messages";
|
||||
in property <string> room-name;
|
||||
in property <string> room-topic;
|
||||
in property <bool> room-is-dm;
|
||||
in property <bool> room-encrypted;
|
||||
in property <bool> room-open: false;
|
||||
in property <int> room-nonce;
|
||||
in property <bool> loading-older;
|
||||
in property <string> typing-text;
|
||||
in property <string> toast;
|
||||
|
||||
// ---- State owned by the UI --------------------------------------------
|
||||
in-out property <string> selected-server-id; // "" = Home
|
||||
in-out property <string> selected-room-id;
|
||||
in-out property <bool> show-members: true;
|
||||
// Composer mode
|
||||
in-out property <string> reply-to-id;
|
||||
in-out property <string> reply-to-name;
|
||||
in-out property <string> editing-id;
|
||||
in-out property <string> composer-text;
|
||||
in-out property <int> composer-text-nonce;
|
||||
in-out property <int> composer-focus-nonce;
|
||||
|
||||
// ---- Intents sent to Rust ---------------------------------------------
|
||||
callback login(string, string, string);
|
||||
callback logout();
|
||||
callback select-server(string);
|
||||
callback select-room(string);
|
||||
callback send-message(string, string); // reply-to-id (may be ""), text
|
||||
callback edit-message(string, string); // event id, new text
|
||||
callback delete-message(string);
|
||||
callback toggle-reaction(string, string);
|
||||
callback load-older();
|
||||
callback typing();
|
||||
callback not-implemented(string); // feature name, for stubs
|
||||
|
||||
if root.screen == Screen.loading: Rectangle {
|
||||
background: Theme.rail-bg;
|
||||
Text {
|
||||
text: "Tofu";
|
||||
color: Theme.text-muted;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
if root.screen == Screen.login: LoginScreen {
|
||||
error: root.login-error;
|
||||
busy: root.login-busy;
|
||||
homeserver <=> root.login-homeserver;
|
||||
username <=> root.login-username;
|
||||
submit(hs, user, pass) => {
|
||||
root.login(hs, user, pass);
|
||||
}
|
||||
}
|
||||
|
||||
if root.screen == Screen.main: HorizontalLayout {
|
||||
// Left column: rail + channel list on top, "you" panel along the bottom.
|
||||
VerticalLayout {
|
||||
width: Theme.rail-width + Theme.sidebar-width;
|
||||
HorizontalLayout {
|
||||
ServerRail {
|
||||
servers: root.servers;
|
||||
selected-id: root.selected-server-id;
|
||||
home-unread: root.home-unread;
|
||||
home-mentions: root.home-mentions;
|
||||
select(id) => {
|
||||
root.selected-server-id = id;
|
||||
root.select-server(id);
|
||||
}
|
||||
add-server => {
|
||||
root.not-implemented("Create / join a space");
|
||||
}
|
||||
}
|
||||
|
||||
ChannelList {
|
||||
title: root.sidebar-title;
|
||||
is-home: root.selected-server-id == "";
|
||||
channels: root.channels;
|
||||
selected-id: root.selected-room-id;
|
||||
select(id) => {
|
||||
root.selected-room-id = id;
|
||||
root.select-room(id);
|
||||
}
|
||||
open-server-menu => {
|
||||
root.not-implemented("Space settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UserPanel {
|
||||
name: root.me-name;
|
||||
user-id: root.me-id;
|
||||
initial: root.me-initial;
|
||||
tint: root.me-color;
|
||||
logout => {
|
||||
root.logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right: chat
|
||||
VerticalLayout {
|
||||
ChatHeader {
|
||||
room-name: root.room-name;
|
||||
topic: root.room-topic;
|
||||
is-dm: root.room-is-dm;
|
||||
encrypted: root.room-encrypted;
|
||||
show-members <=> root.show-members;
|
||||
open-threads => {
|
||||
root.not-implemented("Threads");
|
||||
}
|
||||
open-pins => {
|
||||
root.not-implemented("Pinned messages");
|
||||
}
|
||||
open-search => {
|
||||
root.not-implemented("Search");
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
VerticalLayout {
|
||||
MessageList {
|
||||
messages: root.messages;
|
||||
room-name: root.room-name;
|
||||
loading-older: root.loading-older;
|
||||
room-nonce: root.room-nonce;
|
||||
reply(id, sender) => {
|
||||
root.editing-id = "";
|
||||
root.reply-to-id = id;
|
||||
root.reply-to-name = sender;
|
||||
root.composer-focus-nonce += 1;
|
||||
}
|
||||
edit(id, body) => {
|
||||
root.reply-to-id = "";
|
||||
root.editing-id = id;
|
||||
root.composer-text = body;
|
||||
root.composer-text-nonce += 1;
|
||||
}
|
||||
delete(id) => {
|
||||
root.delete-message(id);
|
||||
}
|
||||
react(id, key) => {
|
||||
root.toggle-reaction(id, key);
|
||||
}
|
||||
load-older => {
|
||||
root.load-older();
|
||||
}
|
||||
}
|
||||
|
||||
if root.room-open: Composer {
|
||||
text-to-set: root.composer-text;
|
||||
text-nonce: root.composer-text-nonce;
|
||||
focus-nonce: root.composer-focus-nonce;
|
||||
placeholder: root.room-is-dm ? "Message @\{root.room-name}" : "Message #\{root.room-name}";
|
||||
banner: root.editing-id != "" ? "Editing message" : (root.reply-to-id != "" ? "Replying to \{root.reply-to-name}" : "");
|
||||
editing: root.editing-id != "";
|
||||
typing-text: root.typing-text;
|
||||
submit(text) => {
|
||||
if (root.editing-id != "") {
|
||||
root.edit-message(root.editing-id, text);
|
||||
} else {
|
||||
root.send-message(root.reply-to-id, text);
|
||||
}
|
||||
root.editing-id = "";
|
||||
root.reply-to-id = "";
|
||||
}
|
||||
cancel-banner => {
|
||||
root.editing-id = "";
|
||||
root.reply-to-id = "";
|
||||
root.composer-text = "";
|
||||
root.composer-text-nonce += 1;
|
||||
}
|
||||
typing => {
|
||||
root.typing();
|
||||
}
|
||||
attach => {
|
||||
root.not-implemented("File uploads");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.show-members: MemberList {
|
||||
members: root.members;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transient banner for errors and "coming soon" notices.
|
||||
if root.toast != "": Rectangle {
|
||||
width: min(480px, parent.width - 32px);
|
||||
height: 40px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: parent.height - self.height - 96px;
|
||||
border-radius: 6px;
|
||||
background: Theme.popup-bg;
|
||||
border-width: 1px;
|
||||
border-color: Theme.divider;
|
||||
Text {
|
||||
x: 12px;
|
||||
width: parent.width - 24px;
|
||||
text: root.toast;
|
||||
color: Theme.text-normal;
|
||||
font-size: 14px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
// All colours and metrics live here so re-theming is a one-file change.
|
||||
// The palette is a familiar dark-grey layered scheme (darkest = server rail,
|
||||
// lightest = chat area) which is what Discord users expect to see.
|
||||
export global Theme {
|
||||
// --- Surfaces -----------------------------------------------------------
|
||||
out property <color> rail-bg: #1e1f22;
|
||||
out property <color> sidebar-bg: #2b2d31;
|
||||
out property <color> chat-bg: #313338;
|
||||
out property <color> input-bg: #383a40;
|
||||
out property <color> popup-bg: #111214;
|
||||
out property <color> hover-bg: #35373c;
|
||||
out property <color> selected-bg: #404249;
|
||||
out property <color> message-hover-bg: #2e3035;
|
||||
out property <color> divider: #3f4147;
|
||||
|
||||
// --- Text ---------------------------------------------------------------
|
||||
out property <color> text-normal: #dbdee1;
|
||||
out property <color> text-muted: #949ba4;
|
||||
out property <color> text-faint: #6d6f78;
|
||||
out property <color> text-header: #f2f3f5;
|
||||
out property <color> text-link: #00a8fc;
|
||||
|
||||
// --- Accents ------------------------------------------------------------
|
||||
out property <color> accent: #6f7bf7;
|
||||
out property <color> accent-hover: #5b67e0;
|
||||
out property <color> danger: #da373c;
|
||||
out property <color> success: #23a559;
|
||||
out property <color> mention-bg: #f0b13222;
|
||||
out property <color> mention-bar: #f0b132;
|
||||
|
||||
// --- Metrics ------------------------------------------------------------
|
||||
out property <length> rail-width: 72px;
|
||||
out property <length> sidebar-width: 240px;
|
||||
out property <length> member-width: 240px;
|
||||
out property <length> header-height: 48px;
|
||||
out property <length> gutter: 56px; // avatar column inside a message row
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/// TEMPORARY AI GENERATED FRONTEND \\\
|
||||
/// NOT TO BE USED IN PRODUCTION \\\
|
||||
|
||||
// Data shapes shared between Rust and Slint. Rust fills models of these; the UI
|
||||
// only renders them. Keep them flat and dumb.
|
||||
|
||||
export enum Screen { loading, login, main }
|
||||
export enum ChannelKind { text, voice, category }
|
||||
export enum MessageKind { message, date-divider, notice, start-of-room }
|
||||
|
||||
// A Discord "server" == a top-level Matrix Space.
|
||||
export struct ServerItem {
|
||||
id: string,
|
||||
name: string,
|
||||
initial: string,
|
||||
color: color,
|
||||
unread: bool,
|
||||
mentions: int,
|
||||
}
|
||||
|
||||
// A Discord "channel" == a Matrix room. A "category" == a sub-space.
|
||||
// In Home mode the same struct carries DMs (is-dm = true).
|
||||
export struct ChannelItem {
|
||||
id: string,
|
||||
name: string,
|
||||
topic: string,
|
||||
kind: ChannelKind,
|
||||
unread: bool,
|
||||
mentions: int,
|
||||
joined: bool,
|
||||
is-dm: bool,
|
||||
initial: string,
|
||||
color: color,
|
||||
}
|
||||
|
||||
export struct ReactionItem {
|
||||
key: string,
|
||||
count: int,
|
||||
mine: bool,
|
||||
}
|
||||
|
||||
// One row of the message list. Date dividers, notices and the "start of room"
|
||||
// banner are rows too, so the list is a single flat model.
|
||||
export struct MessageItem {
|
||||
id: string,
|
||||
kind: MessageKind,
|
||||
sender: string,
|
||||
initial: string,
|
||||
color: color,
|
||||
body: string,
|
||||
time: string,
|
||||
short-time: string,
|
||||
show-header: bool,
|
||||
edited: bool,
|
||||
own: bool,
|
||||
pending: bool,
|
||||
has-reply: bool,
|
||||
reply-sender: string,
|
||||
reply-body: string,
|
||||
reactions: [ReactionItem],
|
||||
}
|
||||
|
||||
// Member list rows. Header rows ("ADMINS — 2") are rows too.
|
||||
export struct MemberItem {
|
||||
id: string,
|
||||
name: string,
|
||||
initial: string,
|
||||
color: color,
|
||||
is-header: bool,
|
||||
}
|
||||