mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Implements the two highest-impact gaps from the client UX spec. Optimistic send: - messages.store gains addOptimisticMessage / markSendFailed / removeOptimistic, and confirmSend now stamps the real id + "sent" on the ack. addMessage reconciles the broadcast by real id (idempotent, replay-safe) with a defensive author match, so an echo never duplicates. Message gains status/correlationId/errorCode. - ChannelController.performSend renders a pending row immediately and supports retry / delete-draft (retry preserves attachments). - MessageList renders pending (dimmed) and failed (reason + Retry / Delete) rows; the hover action bar is limited to confirmed rows. - Failures are precise: the server echoes the request id on error replies (buildErrorMsgWithID), so the dispatcher maps SLOW_MODE / FORBIDDEN / RATE_LIMITED / BAD_REQUEST to the exact row instead of dropping the code. An offline send is shown failed, not silently lost. Composer permission + connection gating: - The server computes an authoritative per-channel can_send in the ready payload (channelCanSend mirrors MessageService.checkSendPermission: READ|SEND, MANAGE_MESSAGES for announcement, admin bypass, channel overrides). channels.store carries it as Channel.canSend. - MessageInput gains a disabled-with-reason mode; ChannelController derives the reason from can_send + channel type + connection status and disables the composer reactively (announcement read-only, no-permission, reconnecting) rather than accepting a click and failing. Older servers that omit can_send default permissive. Docs: the corresponding "Current gap" callouts in docs/architecture/ux are updated to reflect the implementation. Verified: full server suite + client tsc + 3204 unit tests + lint + gofmt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
76 lines
2.7 KiB
Go
76 lines
2.7 KiB
Go
package ws
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
)
|
|
|
|
// TestChannelCanSend locks the composer-gating rule the client relies on:
|
|
// it must mirror MessageService.checkSendPermission for non-DM channels.
|
|
func TestChannelCanSend(t *testing.T) {
|
|
admin := &db.Role{Permissions: permissions.Administrator}
|
|
member := &db.Role{Permissions: permissions.ReadMessages | permissions.SendMessages}
|
|
reader := &db.Role{Permissions: permissions.ReadMessages}
|
|
mod := &db.Role{Permissions: permissions.ReadMessages | permissions.SendMessages | permissions.ManageMessages}
|
|
none := db.ChannelOverride{}
|
|
|
|
cases := []struct {
|
|
name string
|
|
role *db.Role
|
|
o db.ChannelOverride
|
|
ctype string
|
|
want bool
|
|
}{
|
|
{"nil role fails closed", nil, none, "text", false},
|
|
{"admin bypasses on text", admin, none, "text", true},
|
|
{"admin bypasses on announcement", admin, none, "announcement", true},
|
|
{"member can post in text", member, none, "text", true},
|
|
{"reader without SEND cannot post", reader, none, "text", false},
|
|
{"member without MANAGE cannot post in announcement", member, none, "announcement", false},
|
|
{"moderator can post in announcement", mod, none, "announcement", true},
|
|
{"override deny SEND blocks text", member, db.ChannelOverride{Deny: permissions.SendMessages}, "text", false},
|
|
{"override allow MANAGE enables announcement", member, db.ChannelOverride{Allow: permissions.ManageMessages}, "announcement", true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := channelCanSend(c.role, c.o, c.ctype); got != c.want {
|
|
t.Errorf("%s: channelCanSend = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildErrorMsgWithID echoes the request id so the client can correlate a
|
|
// failure with the specific command it sent; an empty id omits the field.
|
|
func TestBuildErrorMsgWithID(t *testing.T) {
|
|
withID := buildErrorMsgWithID(ErrCodeSlowMode, "slow down", "req-42")
|
|
var env struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
Payload struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"payload"`
|
|
}
|
|
if err := json.Unmarshal(withID, &env); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if env.ID != "req-42" {
|
|
t.Errorf("id = %q, want req-42", env.ID)
|
|
}
|
|
if env.Payload.Code != ErrCodeSlowMode {
|
|
t.Errorf("code = %q, want %q", env.Payload.Code, ErrCodeSlowMode)
|
|
}
|
|
|
|
// Empty request id falls back to the id-less envelope.
|
|
noID := buildErrorMsgWithID(ErrCodeSlowMode, "slow down", "")
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(noID, &raw); err != nil {
|
|
t.Fatalf("unmarshal noID: %v", err)
|
|
}
|
|
if _, present := raw["id"]; present {
|
|
t.Error("empty reqID should omit the id field")
|
|
}
|
|
}
|