Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,25 @@ LineTalk is designed for fluid, 100% keyboard-driven interaction:
| <kbd>?</kbd> / <kbd>Ctrl+H</kbd> / <kbd>F1</kbd> | Global | Open **Interactive Keybinding Help** |
| <kbd>q</kbd> / <kbd>Ctrl+C</kbd> | Global | Quit LineTalk cleanly |

### Find Someone And Chat

Press `Esc` if you are typing, then `n` to open New Direct Message. Enter at least
two characters of a username. Search is case-insensitive and matches username
prefixes, returning up to 20 users with their display names and handles.
Use the up/down arrows to select a result, `Enter` to open the conversation, or
`Esc` to cancel. Existing conversations are reused; new conversations are saved
on the server before appearing in the sidebar. Press `i` to start typing.

Discovery is available to authenticated users through
`GET /api/v1/users/search?q=...`. Results contain only ID, name, and username and
exclude your own account. Friendship requests and private discovery settings are
not part of this feature.

Database integration tests require `TEST_DATABASE_URL` pointing to a disposable
PostgreSQL database. They apply the project migrations and test search and
concurrent DM creation. The password-free query test also requires permission to
create a temporary test role. Run them with `make test-api`.

---

## 📋 Makefile Reference
Expand Down
16 changes: 6 additions & 10 deletions internal/api/database/conversation.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions internal/api/database/users.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- +goose Up
-- +goose StatementBegin
CREATE INDEX IF NOT EXISTS idx_users_lower_username_pattern
ON users (LOWER(username) text_pattern_ops);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_users_lower_username_pattern;
-- +goose StatementEnd
16 changes: 6 additions & 10 deletions internal/api/db/query/conversation.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,14 @@ new_conv AS (
FROM
params
ON CONFLICT (user_min_id, user_max_id) WHERE is_deleting = FALSE AND user_min_id IS NOT NULL
DO NOTHING
-- RETURNING also exposes a concurrent winner outside this statement's snapshot.
DO UPDATE SET user_min_id = EXCLUDED.user_min_id
RETURNING
id
),

target_conv AS (
SELECT id FROM new_conv
UNION ALL
SELECT c.id
FROM conversations c, params p
WHERE c.user_min_id = p.min_id
AND c.user_max_id = p.max_id
AND c.is_deleting = FALSE
LIMIT 1
),

inserted_participants AS (
Expand All @@ -51,8 +45,10 @@ SELECT
u.username
FROM
target_conv tc
JOIN participants p ON p.conv_id = tc.id
JOIN users u ON u.id = p.user_id;
-- Base-table scans cannot see participants inserted by a sibling CTE.
CROSS JOIN (VALUES (sqlc.arg(user_id)::bigint), (sqlc.arg(user_id_2)::bigint)) AS requested(id)
JOIN users u ON u.id = requested.id
ORDER BY u.id;

-- name: GetConversationsByUserId :many
WITH
Expand Down
9 changes: 9 additions & 0 deletions internal/api/db/query/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ RETURNING
created_at,
updated_at;

-- name: SearchUsers :many
SELECT id, name, username
FROM users
WHERE id <> sqlc.arg(user_id)::bigint
AND LOWER(username) LIKE LOWER(sqlc.arg(pattern)::text) ESCAPE '\'
ORDER BY (LOWER(username) = LOWER(sqlc.arg(query)::text)) DESC,
LOWER(username) COLLATE "C", username COLLATE "C", id
LIMIT 20;

-- name: GetUserByUsername :one
SELECT
id,
Expand Down
2 changes: 1 addition & 1 deletion internal/api/middleware/ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func AuthRateLimiter(ctx context.Context, ratePerMinute float64, burst int) gin.
c.Header("Retry-After", "15")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"success": false,
"error": "Too many unauthenticated attempts. Please slow down and try again.",
"error": "Too many requests. Please slow down and try again.",
})

return
Expand Down
1 change: 1 addition & 0 deletions internal/api/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func NewRouter(h Handlers, ctx context.Context) *gin.Engine {
subRouter.Use(middleware.Auth())
{
// users routes
subRouter.GET("/users/search", middleware.AuthRateLimiter(ctx, 60, 20), h.User.SearchUsers)
subRouter.GET(
"/users/:id",
middleware.ValidateURIParams[types.URLParamInt](),
Expand Down
10 changes: 10 additions & 0 deletions internal/api/users/dto.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
// Package users
package users

type PublicUser struct {
ID int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
}

type SearchUsersResponse struct {
Users []PublicUser `json:"users"`
}

type CreateUserType struct {
Name string
Username string
Expand Down
16 changes: 16 additions & 0 deletions internal/api/users/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
)

type UserHandlerInt interface {
SearchUsers(c *gin.Context)
GetUserByUsername(c *gin.Context)
GetUserByID(c *gin.Context)
}
Expand All @@ -24,6 +25,21 @@ func NewUserHandler(s UserServiceInt) *UserHandler {

var _ UserHandlerInt = (*UserHandler)(nil)

func (h *UserHandler) SearchUsers(c *gin.Context) {
userID, ok := c.Get("userId")
id, valid := userID.(int64)
if !ok || !valid || id <= 0 {
helpers.WriteError(c, apierrs.NewUnauthorizedError("unauthorized"))
return
}
result, err := h.s.SearchUsers(c.Request.Context(), id, c.Query("q"))
if err != nil {
helpers.WriteError(c, err)
return
}
helpers.WriteJSON(c, http.StatusOK, result)
}

func (h *UserHandler) GetUserByUsername(c *gin.Context) {

ctx := c.Request.Context()
Expand Down
20 changes: 20 additions & 0 deletions internal/api/users/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@ import (
"context"
"fmt"
"log"
"strings"
"time"

"github.com/DanielJohn17/tui-chat/internal/api/database"
)

type UserRepositoryInt interface {
SearchUsers(ctx context.Context, userID int64, query string) ([]PublicUser, error)
CreateUser(ctx context.Context, user CreateUserType) (*CreateUserResponseType, error)
GetUserByUsername(ctx context.Context, username string) (*GetUserResponseType, error)
GetUserByID(ctx context.Context, id int64) (*GetUserResponseType, error)
DeleteUser(ctx context.Context, id int64) (*DeleteUserResponseType, error)
}

type UserQuerier interface {
SearchUsers(ctx context.Context, arg database.SearchUsersParams) ([]database.SearchUsersRow, error)
CreateUser(ctx context.Context, arg database.CreateUserParams) (database.CreateUserRow, error)
GetUserByUsername(ctx context.Context, username string) (database.GetUserByUsernameRow, error)
GetUserById(ctx context.Context, id int64) (database.GetUserByIdRow, error)
Expand All @@ -33,6 +36,23 @@ func NewUserRepository(q UserQuerier) *UserRepository {

var _ UserRepositoryInt = (*UserRepository)(nil)

func (r *UserRepository) SearchUsers(ctx context.Context, userID int64, query string) ([]PublicUser, error) {
pattern := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query) + "%"
rows, err := r.q.SearchUsers(ctx, database.SearchUsersParams{
UserID: userID,
Pattern: pattern,
Query: query,
})
if err != nil {
return nil, fmt.Errorf("search users: %w", err)
}
result := make([]PublicUser, 0, len(rows))
for _, row := range rows {
result = append(result, PublicUser{ID: row.ID, Name: row.Name, Username: row.Username})
}
return result, nil
}

func (r *UserRepository) CreateUser(
ctx context.Context,
user CreateUserType,
Expand Down
18 changes: 18 additions & 0 deletions internal/api/users/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package users

import (
"context"
"strings"
"unicode/utf8"

"github.com/DanielJohn17/tui-chat/internal/api/apierrs"
)

type UserServiceInt interface {
SearchUsers(ctx context.Context, userID int64, query string) (*SearchUsersResponse, error)
CreateUser(
ctx context.Context,
input CreateUserType,
Expand Down Expand Up @@ -38,6 +41,21 @@ func NewUserService(r UserRepositoryInt) *UserService {

var _ UserServiceInt = (*UserService)(nil)

func (s *UserService) SearchUsers(ctx context.Context, userID int64, query string) (*SearchUsersResponse, error) {
query = strings.TrimSpace(query)
if n := utf8.RuneCountInString(query); !utf8.ValidString(query) || n < 2 || n > 20 {
return nil, apierrs.NewBadRequestError("query must be between 2 and 20 characters")
}
result, err := s.r.SearchUsers(ctx, userID, query)
if err != nil {
return nil, apierrs.NewInternalServerError("error searching users", err)
}
if result == nil {
result = []PublicUser{}
}
return &SearchUsersResponse{Users: result}, nil
}

func (s *UserService) CreateUser(
ctx context.Context,
input CreateUserType,
Expand Down
21 changes: 16 additions & 5 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"context"
"fmt"
"time"

Expand Down Expand Up @@ -113,11 +114,13 @@ type AppModel struct {
notificationText string
notificationID int64

authView auth.Model
sidebarView *sidebar.Model
chatView chat.Model
profileView profile.Model
newDMView modals.NewDMModel
authView auth.Model
sidebarView *sidebar.Model
chatView chat.Model
profileView profile.Model
newDMView modals.NewDMModel
discoveryGeneration uint64
discoveryCancel context.CancelFunc
}

func NewApp(c client.Client) AppModel {
Expand Down Expand Up @@ -291,10 +294,18 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil

case profile.LogoutMsg:
m.cancelDiscovery()
m.modal = ModalNone
_ = m.client.Logout()
m.state = StateAuth
m.authView.SetErrorMessage("Logged out successfully.")
return m, nil
case DiscoveryDebounceMsg:
return m.handleDiscoveryDebounce(msg)
case DiscoverySearchMsg:
return m.handleDiscoverySearch(msg)
case DiscoveryOpenMsg:
return m.handleDiscoveryOpen(msg)
}

// 1. Modals capture input when open
Expand Down
10 changes: 10 additions & 0 deletions internal/tui/client/client.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
package client

import "context"

type UserSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
}

type Profile struct {
ID int64 `json:"id"`
Name string `json:"name"`
Expand Down Expand Up @@ -88,6 +96,8 @@ type Client interface {
SetSessionPath(string)

// REST Data operations
SearchUsers(context.Context, string) ([]UserSummary, error)
OpenDirectConversation(context.Context, int64) (Chat, error)
FetchChats() ([]Chat, error)
Chats() []Chat
SetChats([]Chat)
Expand Down
Loading
Loading