From d4fe056854dcb4c86e2554cf45628355cec2a3fc Mon Sep 17 00:00:00 2001 From: Astra Date: Tue, 8 Sep 2026 16:29:37 +0100 Subject: [PATCH] welcome message: link @zio with a mention entity --- internal/domain/welcome_message.go | 41 +++++++++++++++++++++++-- internal/domain/welcome_message_test.go | 23 ++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/internal/domain/welcome_message.go b/internal/domain/welcome_message.go index d1669666..28042f11 100644 --- a/internal/domain/welcome_message.go +++ b/internal/domain/welcome_message.go @@ -10,6 +10,14 @@ import ( "time" ) +// officialUpdatesChannelMention is the public @username of the updates channel +// linked from the welcome message when the resolved body includes it. It is +// carried in a MessageEntityMention wherever it appears in body so clients +// render it as a tappable link; the mention itself lives in the default +// templates in login_welcome_template.go (or an admin-panel override), not +// hardcoded here. +const officialUpdatesChannelMention = "@zio" + // OfficialWelcomeMessage builds the account-visible incoming message sent // from the official system account on every completed sign-in (SignUp and // every subsequent SignIn/SignInWithEmail), regardless of delivery channel. @@ -28,13 +36,42 @@ func OfficialWelcomeMessage(userID int64, body string, date int) (Message, error if userID <= 0 || IsSystemUserID(userID) || body == "" || date < 0 || date > math.MaxInt32 { return Message{}, fmt.Errorf("%w: user=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, date) } - return Message{ + msg := Message{ OwnerUserID: userID, Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID}, From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID}, Date: date, Body: body, - }, nil + } + if ent, ok := usernameMentionEntity(body, officialUpdatesChannelMention); ok { + msg.Entities = []MessageEntity{ent} + } + return msg, nil +} + +// usernameMentionEntity locates mention ("@name") in text and returns a +// MessageEntityMention spanning it, with the UTF-16 offset/length clients expect. +func usernameMentionEntity(text, mention string) (MessageEntity, bool) { + before, _, found := strings.Cut(text, mention) + if !found { + return MessageEntity{}, false + } + return MessageEntity{ + Type: MessageEntityMention, + Offset: utf16Len(before), + Length: utf16Len(mention), + }, true +} + +func utf16Len(s string) int { + n := 0 + for _, r := range s { + n++ + if r > 0xffff { + n++ + } + } + return n } // SignInMethodLabel returns the human-readable method name embedded in diff --git a/internal/domain/welcome_message_test.go b/internal/domain/welcome_message_test.go index 0f688ca7..66fd654c 100644 --- a/internal/domain/welcome_message_test.go +++ b/internal/domain/welcome_message_test.go @@ -3,6 +3,7 @@ package domain import ( "errors" "testing" + "unicode/utf16" ) func TestWelcomeMessageContentAndFingerprint(t *testing.T) { @@ -52,3 +53,25 @@ func TestNextWelcomeRevision(t *testing.T) { t.Fatalf("zero revision err = %v", err) } } + +func TestOfficialWelcomeMessageLinksUpdatesChannel(t *testing.T) { + body := "Welcome! Join " + officialUpdatesChannelMention + " for updates." + msg, err := OfficialWelcomeMessage(1780243200, body, 1_700_000_000) + if err != nil { + t.Fatalf("body %q: %v", body, err) + } + if len(msg.Entities) != 1 { + t.Fatalf("body %q: entities = %+v, want one mention", body, msg.Entities) + } + ent := msg.Entities[0] + if ent.Type != MessageEntityMention { + t.Fatalf("entity type = %q, want mention", ent.Type) + } + units := utf16.Encode([]rune(msg.Body)) + if ent.Offset < 0 || ent.Length <= 0 || ent.Offset+ent.Length > len(units) { + t.Fatalf("entity %+v out of bounds for body of %d utf16 units", ent, len(units)) + } + if got := string(utf16.Decode(units[ent.Offset : ent.Offset+ent.Length])); got != officialUpdatesChannelMention { + t.Fatalf("entity spans %q, want %q", got, officialUpdatesChannelMention) + } +}