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() }