feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
This commit is contained in:
1 parent
0a05bfe2cd
commit
11a16cd023
59 files changed
+5022
-542
No files matched your search
@@ -0,0 +1,193 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func validEmoji(s string) bool {
|
||||
switch s {
|
||||
case "❤️", "👍", "🔥", "😂", "🎵", "👏":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (h *Handler) GetChatMeta(ctx context.Context, r gen.GetChatMetaRequestObject) (gen.GetChatMetaResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
tx, e := pairTx(ctx, h.store.pool, id, r.PeerId)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if e = allowed(ctx, tx, id, r.PeerId); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var typing bool
|
||||
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM chat_typing WHERE user_id=$1 AND peer_id=$2 AND expires_at>now())`, r.PeerId, id).Scan(&typing)
|
||||
return gen.GetChatMeta200JSONResponse{Typing: typing}, e
|
||||
}
|
||||
func (h *Handler) SetChatTyping(ctx context.Context, r gen.SetChatTypingRequestObject) (gen.SetChatTypingResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
tx, e := pairTx(ctx, h.store.pool, id, r.PeerId)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if e = allowed(ctx, tx, id, r.PeerId); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body.Typing {
|
||||
_, e = tx.Exec(ctx, `INSERT INTO chat_typing(user_id,peer_id,expires_at) VALUES($1,$2,now()+interval '6 seconds') ON CONFLICT(user_id,peer_id) DO UPDATE SET expires_at=EXCLUDED.expires_at`, id, r.PeerId)
|
||||
} else {
|
||||
_, e = tx.Exec(ctx, `DELETE FROM chat_typing WHERE user_id=$1 AND peer_id=$2`, id, r.PeerId)
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return gen.SetChatTyping204Response{}, tx.Commit(ctx)
|
||||
}
|
||||
func (h *Handler) UpdateChatMessage(ctx context.Context, r gen.UpdateChatMessageRequestObject) (gen.UpdateChatMessageResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
e = h.store.updateMessage(ctx, id, r.PeerId, r.MessageId, *r.Body)
|
||||
return gen.UpdateChatMessage204Response{}, e
|
||||
}
|
||||
func (s *Store) updateMessage(ctx context.Context, id, peer, message uuid.UUID, a gen.UpdateChatMessageJSONRequestBody) error {
|
||||
tx, e := pairTx(ctx, s.pool, id, peer)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if e = allowed(ctx, tx, id, peer); e != nil {
|
||||
return e
|
||||
}
|
||||
var sender uuid.UUID
|
||||
var kind string
|
||||
var deleted bool
|
||||
e = tx.QueryRow(ctx, `SELECT sender_id,attachment_kind,deleted_at IS NOT NULL FROM direct_messages WHERE id=$1 AND LEAST(sender_id,recipient_id)=LEAST($2::uuid,$3::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($2::uuid,$3::uuid) FOR UPDATE`, message, id, peer).Scan(&sender, &kind, &deleted)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return apperr.NotFound("social.message_conflict", "message unavailable")
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if a.Action != "react" && sender != id {
|
||||
return denied()
|
||||
}
|
||||
if deleted {
|
||||
if a.Action == "delete" {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
return apperr.Conflict("social.message_conflict", "message deleted")
|
||||
}
|
||||
switch a.Action {
|
||||
case "edit":
|
||||
if a.Text == nil || utf8.RuneCountInString(*a.Text) > 4000 || (kind == "none" && strings.TrimSpace(*a.Text) == "") {
|
||||
return invalid()
|
||||
}
|
||||
_, e = tx.Exec(ctx, `UPDATE direct_messages SET text=$2,edited_at=now() WHERE id=$1`, message, strings.TrimSpace(*a.Text))
|
||||
case "delete":
|
||||
_, e = tx.Exec(ctx, `UPDATE direct_messages SET text='[deleted]',attachment_kind='none',attachment_id=NULL,deleted_at=now() WHERE id=$1`, message)
|
||||
if e == nil {
|
||||
_, e = tx.Exec(ctx, `DELETE FROM message_reactions WHERE message_id=$1`, message)
|
||||
}
|
||||
if e == nil {
|
||||
_, e = tx.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE message_seq=(SELECT seq FROM direct_messages WHERE id=$1)`, message)
|
||||
}
|
||||
case "react":
|
||||
if a.Emoji == nil || !validEmoji(string(*a.Emoji)) {
|
||||
return invalid()
|
||||
}
|
||||
result, err := tx.Exec(ctx, `DELETE FROM message_reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3`, message, id, *a.Emoji)
|
||||
e = err
|
||||
if e == nil && result.RowsAffected() == 0 {
|
||||
_, e = tx.Exec(ctx, `INSERT INTO message_reactions(message_id,user_id,emoji) VALUES($1,$2,$3)`, message, id, *a.Emoji)
|
||||
}
|
||||
default:
|
||||
return invalid()
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
func enrichMessages(ctx context.Context, tx pgx.Tx, viewer uuid.UUID, messages []gen.DirectMessage) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(messages))
|
||||
replies := []uuid.UUID{}
|
||||
positions := map[uuid.UUID]int{}
|
||||
for i, m := range messages {
|
||||
ids = append(ids, m.Id)
|
||||
positions[m.Id] = i
|
||||
if m.ReplyTo != nil {
|
||||
replies = append(replies, *m.ReplyTo)
|
||||
}
|
||||
empty := []gen.MessageReaction{}
|
||||
messages[i].Reactions = &empty
|
||||
}
|
||||
rows, e := tx.Query(ctx, `SELECT message_id,emoji,count(*),bool_or(user_id=$2) FROM message_reactions WHERE message_id=ANY($1::uuid[]) GROUP BY message_id,emoji ORDER BY emoji`, ids, viewer)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var r gen.MessageReaction
|
||||
if e = rows.Scan(&id, &r.Emoji, &r.Count, &r.Mine); e != nil {
|
||||
rows.Close()
|
||||
return e
|
||||
}
|
||||
i := positions[id]
|
||||
*messages[i].Reactions = append(*messages[i].Reactions, r)
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if len(replies) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, e = tx.Query(ctx, `SELECT id,CASE WHEN deleted_at IS NULL THEN left(text,160) ELSE '' END FROM direct_messages WHERE id=ANY($1::uuid[])`, replies)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer rows.Close()
|
||||
previews := map[uuid.UUID]string{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var text string
|
||||
if e = rows.Scan(&id, &text); e != nil {
|
||||
return e
|
||||
}
|
||||
previews[id] = text
|
||||
}
|
||||
for i, m := range messages {
|
||||
if m.ReplyTo != nil {
|
||||
p := previews[*m.ReplyTo]
|
||||
messages[i].ReplyPreview = &p
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
Reference in new issue
Block a user