fix: chat UI
This commit is contained in:
@@ -65,7 +65,12 @@ type ChatService struct {
|
||||
blocks *repository.BlockRepo
|
||||
// posts resolves shared post cards. Optional: nil leaves entity
|
||||
// attachments unresolved rather than failing a whole thread load.
|
||||
posts *repository.PostRepo
|
||||
posts *repository.PostRepo
|
||||
// push, prefs and users deliver out-of-band message alerts. All optional:
|
||||
// nil means no push, never a failed send.
|
||||
push *PushSender
|
||||
prefs *repository.NotificationPrefsRepo
|
||||
users *repository.UserRepo
|
||||
rt *realtime.SoketiClient
|
||||
r2 *repository.R2
|
||||
links *unfurl.Fetcher
|
||||
@@ -77,13 +82,17 @@ func NewChatService(
|
||||
chat *repository.ChatRepo,
|
||||
blocks *repository.BlockRepo,
|
||||
posts *repository.PostRepo,
|
||||
push *PushSender,
|
||||
prefs *repository.NotificationPrefsRepo,
|
||||
users *repository.UserRepo,
|
||||
rt *realtime.SoketiClient,
|
||||
r2 *repository.R2,
|
||||
cache *repository.Redis,
|
||||
logger *zap.Logger,
|
||||
) *ChatService {
|
||||
return &ChatService{
|
||||
chat: chat, blocks: blocks, posts: posts, rt: rt, r2: r2,
|
||||
chat: chat, blocks: blocks, posts: posts,
|
||||
push: push, prefs: prefs, users: users, rt: rt, r2: r2,
|
||||
links: unfurl.New(), cache: cache, logger: logger,
|
||||
}
|
||||
}
|
||||
@@ -156,6 +165,11 @@ func (s *ChatService) Inbox(ctx context.Context, userID, tab string, limit int)
|
||||
return s.chat.Inbox(ctx, userID, KindsForTab(tab), limit)
|
||||
}
|
||||
|
||||
// UnreadCount reports how many threads are waiting on the caller.
|
||||
func (s *ChatService) UnreadCount(ctx context.Context, userID string) (int, error) {
|
||||
return s.chat.UnreadConversations(ctx, userID)
|
||||
}
|
||||
|
||||
// OpenDM returns (creating if needed) the DM between the caller and peer.
|
||||
//
|
||||
// Blocking is enforced in BOTH directions: a blocked user must not be able to
|
||||
@@ -1579,6 +1593,13 @@ func (s *ChatService) Receipts(ctx context.Context, userID, conversationID strin
|
||||
// publish fans a new message out to the open thread, and separately nudges each
|
||||
// other participant's inbox.
|
||||
func (s *ChatService) publish(ctx context.Context, conversationID, senderID string, msg *model.Message) {
|
||||
// Out-of-band alerts for anyone not currently connected. Best-effort and
|
||||
// after the realtime fan-out, so a slow push never delays delivery to
|
||||
// people who are online.
|
||||
if conv, err := s.chat.Conversation(ctx, conversationID); err == nil {
|
||||
s.notifyNewMessage(ctx, conv, senderID, msg)
|
||||
}
|
||||
|
||||
if s.rt == nil {
|
||||
return
|
||||
}
|
||||
@@ -1613,6 +1634,149 @@ func (s *ChatService) publish(ctx context.Context, conversationID, senderID stri
|
||||
}
|
||||
}
|
||||
|
||||
// pushConfig tunes out-of-band delivery.
|
||||
const (
|
||||
// One push per thread per window, however many messages arrive. A burst
|
||||
// should buzz a pocket once.
|
||||
pushDebounceWindow = 90 * time.Second
|
||||
// How long a live WebSocket connection suppresses push for.
|
||||
presenceTTL = 2 * time.Minute
|
||||
// Lock-screen previews are deliberately senderless of content — see
|
||||
// notifyNewMessage.
|
||||
maxPushPreviewRunes = 120
|
||||
)
|
||||
|
||||
// presenceKey marks "this user has a live realtime connection".
|
||||
func presenceKey(userID string) string { return "chat:online:" + userID }
|
||||
|
||||
// MarkPresence records that a user's realtime channel became occupied or
|
||||
// vacated. Driven by Soketi's webhooks.
|
||||
func (s *ChatService) MarkPresence(ctx context.Context, userID string, online bool) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
if online {
|
||||
s.cache.Client.Set(ctx, presenceKey(userID), "1", presenceTTL)
|
||||
return
|
||||
}
|
||||
s.cache.Client.Del(ctx, presenceKey(userID))
|
||||
}
|
||||
|
||||
// notifyNewMessage delivers a Web Push alert to participants who are not
|
||||
// currently connected.
|
||||
//
|
||||
// Deliberately NOT routed through NotificationService: chat must not create a
|
||||
// notifications row or land in the tray. It borrows only the preference matrix
|
||||
// so "Messages → Push" can be turned off like anything else.
|
||||
//
|
||||
// Four things suppress a push, in the order that costs least to check:
|
||||
// the thread is muted, the user opted out, they have a live connection, or one
|
||||
// already went out for this thread recently.
|
||||
func (s *ChatService) notifyNewMessage(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) {
|
||||
if s.push == nil || !s.push.Enabled() {
|
||||
return
|
||||
}
|
||||
candidates, err := s.chat.PushCandidates(ctx, conv.ID, senderID)
|
||||
if err != nil {
|
||||
s.logger.Warn("chat: push candidates failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
title, body := s.pushCopy(ctx, conv, senderID, msg)
|
||||
now := time.Now()
|
||||
|
||||
for _, c := range candidates {
|
||||
if c.MutedUntil != nil && c.MutedUntil.After(now) {
|
||||
continue
|
||||
}
|
||||
if s.prefs != nil {
|
||||
if off, err := s.prefs.DisabledSet(ctx, c.UserID); err == nil &&
|
||||
prefOff(off, NotifChatMessage, repository.ChannelPush) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if s.online(ctx, c.UserID) {
|
||||
continue
|
||||
}
|
||||
if !s.claimPushSlot(ctx, c.UserID, conv.ID) {
|
||||
continue
|
||||
}
|
||||
s.push.SendToUser(ctx, c.UserID, PushPayload{
|
||||
Title: title,
|
||||
Body: body,
|
||||
URL: "/chats/" + conv.ID,
|
||||
// Same tag per thread: a later message replaces the earlier
|
||||
// notification rather than stacking up.
|
||||
Tag: "chat:" + conv.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pushCopy builds the lock-screen text.
|
||||
func (s *ChatService) pushCopy(ctx context.Context, conv *model.Conversation, senderID string, msg *model.Message) (title, body string) {
|
||||
sender := "Someone"
|
||||
if s.users != nil {
|
||||
if u, err := s.users.FindByID(ctx, senderID); err == nil && u != nil {
|
||||
if u.Name != "" {
|
||||
sender = u.Name
|
||||
} else if u.Handle != "" {
|
||||
sender = "@" + u.Handle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title = sender
|
||||
if conv.Kind == model.ConversationGroup && conv.Title != nil && *conv.Title != "" {
|
||||
title = sender + " · " + *conv.Title
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(msg.Body) != "":
|
||||
body = truncateRunes(strings.TrimSpace(msg.Body), maxPushPreviewRunes)
|
||||
case len(msg.Attachments) > 0:
|
||||
body = "Sent an attachment"
|
||||
default:
|
||||
body = "Sent you a message"
|
||||
}
|
||||
return title, body
|
||||
}
|
||||
|
||||
// online reports whether the user holds a live realtime connection.
|
||||
//
|
||||
// Best-effort and fails OPEN: if the cache is unavailable — or Soketi is not
|
||||
// configured to send channel_occupied — we treat them as offline and push. A
|
||||
// redundant notification while they are reading is a smaller failure than a
|
||||
// message that never arrives.
|
||||
func (s *ChatService) online(ctx context.Context, userID string) bool {
|
||||
if s.cache == nil {
|
||||
return false
|
||||
}
|
||||
n, err := s.cache.Client.Exists(ctx, presenceKey(userID)).Result()
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// claimPushSlot returns true for the first push to a thread within the window.
|
||||
// Fails OPEN for the same reason as online().
|
||||
func (s *ChatService) claimPushSlot(ctx context.Context, userID, conversationID string) bool {
|
||||
if s.cache == nil {
|
||||
return true
|
||||
}
|
||||
ok, err := s.cache.Client.SetNX(ctx,
|
||||
"chat:pushed:"+userID+":"+conversationID, "1", pushDebounceWindow).Result()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(string(r[:n])) + "…"
|
||||
}
|
||||
|
||||
func (s *ChatService) publishReceipt(ctx context.Context, conversationID, userID, kind string, seq int64) {
|
||||
if s.rt == nil {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user