mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat(client): optimistic message send + composer permission gating
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
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildReady_IncludesCanSend confirms every ready channel carries the
|
||||
// can_send affordance flag the client composer keys off.
|
||||
func TestBuildReady_IncludesCanSend(t *testing.T) {
|
||||
hub, database := newCoverageHub(t)
|
||||
user := seedCoverageOwner(t, database, "cansend-user")
|
||||
role, err := database.GetRoleByID(1)
|
||||
if err != nil || role == nil {
|
||||
t.Fatalf("GetRoleByID: %v", err)
|
||||
}
|
||||
if _, err := database.CreateChannel("general", "text", "", "", 0); err != nil {
|
||||
t.Fatalf("CreateChannel: %v", err)
|
||||
}
|
||||
msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Payload struct {
|
||||
Channels []map[string]any `json:"channels"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(env.Payload.Channels) == 0 {
|
||||
t.Fatal("expected at least one channel")
|
||||
}
|
||||
for _, ch := range env.Payload.Channels {
|
||||
canSend, ok := ch["can_send"]
|
||||
if !ok {
|
||||
t.Errorf("channel %v missing can_send", ch["name"])
|
||||
continue
|
||||
}
|
||||
// Owner role → can_send true everywhere.
|
||||
if canSend != true {
|
||||
t.Errorf("channel %v can_send = %v, want true for owner", ch["name"], canSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
|
||||
cmd, parseErr := ctor(c.userID, env.ID, env.Payload)
|
||||
if parseErr != nil {
|
||||
reqLog.Warn("ws command parse error", "err", parseErr)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid payload"))
|
||||
c.sendMsg(buildErrorMsgWithID(ErrCodeBadRequest, "invalid payload", env.ID))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -127,15 +127,15 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
|
||||
result, dispatched := h.registry.DispatchV2(c.ctx, cmd, info)
|
||||
if !dispatched {
|
||||
reqLog.Error("ws V2 handler registered but DispatchV2 returned false", "type", env.Type)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "internal error"))
|
||||
c.sendMsg(buildErrorMsgWithID(ErrCodeInternal, "internal error", env.ID))
|
||||
return
|
||||
}
|
||||
if result.Error != nil {
|
||||
if ce, ok := result.Error.(ClientError); ok {
|
||||
c.sendMsg(buildErrorMsg(ce.Code, ce.Message))
|
||||
c.sendMsg(buildErrorMsgWithID(ce.Code, ce.Message, env.ID))
|
||||
} else {
|
||||
reqLog.Error("ws handler internal error", "err", result.Error)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "internal error"))
|
||||
c.sendMsg(buildErrorMsgWithID(ErrCodeInternal, "internal error", env.ID))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -207,6 +207,24 @@ func buildErrorMsg(code, message string) []byte {
|
||||
})
|
||||
}
|
||||
|
||||
// buildErrorMsgWithID produces an error envelope that echoes the originating
|
||||
// command's request id, so the client can correlate the failure with the
|
||||
// specific command it sent (e.g. mark an optimistic chat_send row as failed).
|
||||
// When reqID is empty it falls back to the id-less envelope.
|
||||
func buildErrorMsgWithID(code, message, reqID string) []byte {
|
||||
if reqID == "" {
|
||||
return buildErrorMsg(code, message)
|
||||
}
|
||||
return buildJSON(map[string]any{
|
||||
"type": MsgTypeError,
|
||||
"id": reqID,
|
||||
"payload": map[string]string{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildAuthError produces an auth_error envelope per PROTOCOL.md.
|
||||
// The client treats this type as non-recoverable and stops reconnecting.
|
||||
func buildAuthError(message string) []byte {
|
||||
|
||||
@@ -564,6 +564,28 @@ func (h *Hub) buildAuthOK(user *db.User, roleName string, replaySource string) [
|
||||
})
|
||||
}
|
||||
|
||||
// channelCanSend reports whether a user with the given role and per-channel
|
||||
// override may post in a channel of chanType. It mirrors the non-DM branch of
|
||||
// MessageService.checkSendPermission so the client can pre-disable the composer
|
||||
// without a round-trip; the server still enforces the rule authoritatively.
|
||||
func channelCanSend(role *db.Role, o db.ChannelOverride, chanType string) bool {
|
||||
if role == nil {
|
||||
return false
|
||||
}
|
||||
if permissions.HasAdmin(role.Permissions) {
|
||||
return true
|
||||
}
|
||||
eff := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny)
|
||||
need := permissions.ReadMessages | permissions.SendMessages
|
||||
if eff&need != need {
|
||||
return false
|
||||
}
|
||||
if chanType == "announcement" {
|
||||
return eff&permissions.ManageMessages == permissions.ManageMessages
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildReady constructs the ready server→client message.
|
||||
// Per PROTOCOL.md, channels include unread_count and last_message_id per user,
|
||||
// and only protocol-specified fields (no slow_mode, archived, voice_* extras).
|
||||
@@ -632,6 +654,12 @@ func (h *Hub) buildReady(database *db.DB, userID int64, role *db.Role) ([]byte,
|
||||
"type": visibleChannels[i].Type,
|
||||
"category": visibleChannels[i].Category,
|
||||
"position": visibleChannels[i].Position,
|
||||
// can_send drives the client's composer affordance. It mirrors
|
||||
// MessageService.checkSendPermission for non-DM channels: base role
|
||||
// ± channel overrides must grant READ|SEND, and announcement
|
||||
// channels additionally require MANAGE_MESSAGES; admins bypass. The
|
||||
// server remains the authority — this only pre-disables the UI.
|
||||
"can_send": channelCanSend(role, overrides[visibleChannels[i].ID], visibleChannels[i].Type),
|
||||
}
|
||||
if visibleChannels[i].Type == "text" || visibleChannels[i].Type == "announcement" {
|
||||
if u, ok := unreadMap[visibleChannels[i].ID]; ok {
|
||||
|
||||
Reference in New Issue
Block a user