Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce7cc8651 | ||
|
|
a25fb83a2a | ||
|
|
a45beb9701 | ||
|
|
6f8349c62f | ||
|
|
6bf2c9983a | ||
|
|
ffac812273 | ||
|
|
fa9d54aab5 | ||
|
|
c4e6cfa29b | ||
|
|
71fe83bd0f | ||
|
|
81f376cf62 | ||
|
|
78c5151f3b | ||
|
|
66d53497c5 | ||
|
|
b9a8529e72 | ||
|
|
95f2362947 | ||
|
|
9fd7e611f3 | ||
|
|
849d41fa06 |
+2
-31
@@ -1,43 +1,14 @@
|
|||||||
name: PR Quality Gate (client applications)
|
name: PR Quality Gate (Desktop)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "js/mobile/**"
|
|
||||||
- "js/desktop/**"
|
- "js/desktop/**"
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
types: [ opened, synchronize, reopened ]
|
types: [ opened, synchronize, reopened ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
mobile:
|
verify:
|
||||||
name: Lint & format check (mobile)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: js/mobile
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Enable Corepack
|
|
||||||
run: corepack enable
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --immutable
|
|
||||||
|
|
||||||
- name: Run Code Linter
|
|
||||||
run: yarn lint
|
|
||||||
|
|
||||||
- name: Run Format Check
|
|
||||||
run: yarn format:check
|
|
||||||
|
|
||||||
desktop:
|
|
||||||
name: Lint & format check (desktop)
|
name: Lint & format check (desktop)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: PR Quality Gate (Mobile)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "js/mobile/**"
|
||||||
|
branches: [ main ]
|
||||||
|
types: [ opened, synchronize, reopened ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Lint & format check (mobile)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: js/mobile
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable
|
||||||
|
|
||||||
|
- name: Run Code Linter
|
||||||
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Run Format Check
|
||||||
|
run: yarn format:check
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
# Project Rules
|
# Project Rules
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- Electron typescript/react app is in `js/` folder
|
- Electron typescript/react app is in `js/desktop` folder
|
||||||
- Orion is the api server which lives in the `go/` folder
|
- Mobile react native expo app is in `js/mobile`
|
||||||
|
- Orion is the api server which lives in the `go/` folder along with other workers, jobs, services
|
||||||
- `cpp/` points to our prototype of a C++ Qt widgets client
|
- `cpp/` points to our prototype of a C++ Qt widgets client
|
||||||
|
|
||||||
Whenever implementing anything, make sure to take into account best practices without over-engineering.
|
Whenever implementing anything, make sure to take into account best practices without over-engineering.
|
||||||
|
|
||||||
## Electron App
|
## Electron App
|
||||||
### Quality
|
### Quality
|
||||||
We care about overall architectural quality and keeping consistent patterns according to best practices.
|
We care about overall architectural quality and keeping consistent patterns according to best practices. Feel free to move stuff around to make things more elegant, instead of bolting on features.
|
||||||
|
|
||||||
Another tradeoff we make is simpler, maintainable code over clever behavior.
|
Another tradeoff we make is simpler, maintainable code over clever behavior.
|
||||||
|
|
||||||
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
|
As an example, we have as high of a bar as a product team like Linear and Apple, which outputs high quality software. Let's avoid slop at all costs.
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
Don't add non-essential comments everywhere. Clean them up if so.
|
||||||
|
|
||||||
### Package Manager
|
### Package Manager
|
||||||
- Use **yarn** (not npm) for all dependency management
|
- Use **yarn** (not npm) for all dependency management
|
||||||
|
|
||||||
@@ -25,6 +29,3 @@ Whenever possible, we should use the design system components. If we need to add
|
|||||||
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
|
When adding a feature on the client side, make sure that the api actually supports it by just checking orion implementation all the way through.
|
||||||
|
|
||||||
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
|
IF you find that the API is poorly designed, please suggest changes to improve the client experience.
|
||||||
|
|
||||||
## Verification
|
|
||||||
Check using `yarn compile` which lives in the package.json as a script.
|
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ func main() {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
mux.Handle("PATCH /humans/me/settings", withAuth(h.UpdateSettings))
|
||||||
|
mux.Handle("PUT /humans/me/avatar", withAuth(h.UpdateAvatar))
|
||||||
|
mux.Handle("DELETE /humans/me/avatar", withAuth(h.DeleteAvatar))
|
||||||
|
|
||||||
|
// Get avatar download url, given objectId
|
||||||
|
mux.Handle("GET /humans/avatar/{id}", withAuth(h.GetObjectDownloadUrl))
|
||||||
|
|
||||||
// Push notification tokens (per-device)
|
// Push notification tokens (per-device)
|
||||||
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
|
mux.Handle("POST /humans/me/push-tokens", withAuth(h.RegisterPushToken))
|
||||||
@@ -174,7 +179,7 @@ func main() {
|
|||||||
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
|
mux.Handle("POST /invitations/accept", withAuth(h.AcceptInvitation))
|
||||||
|
|
||||||
// Particles
|
// Particles
|
||||||
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
|
mux.Handle("GET /particles/{id}/download", withAuth(h.GetObjectDownloadUrl))
|
||||||
|
|
||||||
// Link metadata
|
// Link metadata
|
||||||
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type Human struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
EmailPrefix string `json:"email_prefix"`
|
EmailPrefix string `json:"email_prefix"`
|
||||||
EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
|
EmailNotificationsEnabled bool `json:"email_notifications_enabled"`
|
||||||
|
AvatarObjectID *string `json:"avatar_object_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +335,83 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
human, err := h.humanSvc.GetByID(r.Context(), humanId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to get human by id", "error", err, "humanId", humanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.humanSvc.DeleteAvatar(r.Context(), humanId)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to delete avatar from human", "error", err)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if human.AvatarObjectID != nil {
|
||||||
|
err = h.depotSvc.Delete(r.Context(), utils.OptionalString(human.AvatarObjectID))
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to delete object", "error", err, "objectID", human.AvatarObjectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5MB limit = 5 * 1024 * 1024 bytes
|
||||||
|
const maxBodySize = 5 << 20
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||||
|
|
||||||
|
object, err := h.depotSvc.CreateFromReader(r.Context(), depot.CreateFromReaderInput{
|
||||||
|
Prefix: "avatars",
|
||||||
|
Name: fmt.Sprintf("%s-avatar", humanId),
|
||||||
|
ContentType: r.Header.Get("Content-Type"),
|
||||||
|
}, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) {
|
||||||
|
http.Error(w, "avatar file too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flog.Error("failed to upload avatar with depo", "error", err, "humanId", humanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.humanSvc.UpdateAvatar(r.Context(), humanId, object.ID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("failed to update human avatar", "error", err, "humanId", humanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
// best effort
|
||||||
|
err = h.depotSvc.Delete(r.Context(), object.ID)
|
||||||
|
if err != nil {
|
||||||
|
flog.Error("best-effort delete of object failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Network Handlers
|
// Network Handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -717,8 +795,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadParticleMedia returns a fresh signed URL for media/file particles.
|
// GetObjectDownloadUrl returns a fresh signed URL for media/file particles.
|
||||||
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetObjectDownloadUrl(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := middleware.EmailFromContext(r.Context())
|
_, ok := middleware.EmailFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
@@ -1007,6 +1085,7 @@ func humanToDTO(h *human.Human) Human {
|
|||||||
Email: h.Email,
|
Email: h.Email,
|
||||||
EmailPrefix: h.EmailPrefix,
|
EmailPrefix: h.EmailPrefix,
|
||||||
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
|
EmailNotificationsEnabled: h.EmailNotificationsEnabled,
|
||||||
|
AvatarObjectID: h.AvatarObjectID,
|
||||||
CreatedAt: h.CreatedAt,
|
CreatedAt: h.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type Human struct {
|
|||||||
Email string
|
Email string
|
||||||
EmailPrefix string
|
EmailPrefix string
|
||||||
EmailNotificationsEnabled bool
|
EmailNotificationsEnabled bool
|
||||||
|
AvatarObjectID *string
|
||||||
LastEmailNotificationSentAt *time.Time
|
LastEmailNotificationSentAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type repository interface {
|
|||||||
listAll(ctx context.Context) ([]*Human, error)
|
listAll(ctx context.Context) ([]*Human, error)
|
||||||
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
updateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
updateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
updateAvatarObjectID(ctx context.Context, id string, objectID *string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type repositoryImpl struct {
|
type repositoryImpl struct {
|
||||||
@@ -50,9 +51,9 @@ func newRepository(pool *pgxpool.Pool) repository {
|
|||||||
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
||||||
var h Human
|
var h Human
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE email = $1`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE email = $1`,
|
||||||
email,
|
email,
|
||||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
@@ -66,9 +67,9 @@ func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human,
|
|||||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
||||||
var h Human
|
var h Human
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans WHERE id = $1`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt)
|
).Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
@@ -113,7 +114,7 @@ func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error)
|
|||||||
|
|
||||||
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at FROM humans`,
|
`SELECT id, email, email_notifications_enabled, last_email_notification_sent_at, created_at, avatar_object_id FROM humans`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -123,7 +124,7 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Human, error) {
|
|||||||
var humans []*Human
|
var humans []*Human
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var h Human
|
var h Human
|
||||||
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt); err != nil {
|
if err := rows.Scan(&h.ID, &h.Email, &h.EmailNotificationsEnabled, &h.LastEmailNotificationSentAt, &h.CreatedAt, &h.AvatarObjectID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.EmailPrefix = emailPrefix(h.Email)
|
h.EmailPrefix = emailPrefix(h.Email)
|
||||||
@@ -159,3 +160,17 @@ func (r *repositoryImpl) updateLastEmailNotificationSentAt(ctx context.Context,
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) updateAvatarObjectID(ctx context.Context, id string, objectID *string) error {
|
||||||
|
result, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE humans SET avatar_object_id = $2 WHERE id = $1`,
|
||||||
|
id, objectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return errNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import (
|
|||||||
|
|
||||||
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
|
||||||
|
|
||||||
var ErrNotFound = errors.New("human not found")
|
var (
|
||||||
|
ErrNotFound = errors.New("human not found")
|
||||||
|
ErrInvalidParam = errors.New("invalid param")
|
||||||
|
)
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
||||||
@@ -22,6 +25,8 @@ type Service interface {
|
|||||||
ListAll(ctx context.Context) ([]*Human, error)
|
ListAll(ctx context.Context) ([]*Human, error)
|
||||||
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
UpdateEmailNotificationsEnabled(ctx context.Context, id string, enabled bool) error
|
||||||
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
UpdateLastEmailNotificationSentAt(ctx context.Context, id string, t time.Time) error
|
||||||
|
UpdateAvatar(ctx context.Context, id string, objectID string) error
|
||||||
|
DeleteAvatar(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceImpl struct {
|
type serviceImpl struct {
|
||||||
@@ -88,3 +93,22 @@ func (s *serviceImpl) UpdateLastEmailNotificationSentAt(ctx context.Context, id
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) UpdateAvatar(ctx context.Context, id string, objectID string) error {
|
||||||
|
if objectID == "" {
|
||||||
|
return ErrInvalidParam
|
||||||
|
}
|
||||||
|
err := s.repo.updateAvatarObjectID(ctx, id, utils.CreateOptionalString(objectID))
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) DeleteAvatar(ctx context.Context, id string) error {
|
||||||
|
err := s.repo.updateAvatarObjectID(ctx, id, nil)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,4 +56,19 @@ func TestHumanService(t *testing.T) {
|
|||||||
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
||||||
assert.Equal(t, "[email protected]", anotherHuman.Email)
|
assert.Equal(t, "[email protected]", anotherHuman.Email)
|
||||||
assert.Equal(t, "another", anotherHuman.EmailPrefix)
|
assert.Equal(t, "another", anotherHuman.EmailPrefix)
|
||||||
|
|
||||||
|
// Test avatar handling
|
||||||
|
objectID := "obj_xxx"
|
||||||
|
err = svc.UpdateAvatar(ctx, anotherHuman.ID, objectID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, objectID, *anotherHuman.AvatarObjectID)
|
||||||
|
|
||||||
|
err = svc.DeleteAvatar(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
anotherHuman, err = svc.GetByID(ctx, anotherHuman.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, anotherHuman.AvatarObjectID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE humans
|
||||||
|
DROP COLUMN IF EXISTS avatar_object_id;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE humans
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_object_id TEXT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -42,16 +42,12 @@ class ApiClient {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch(
|
private async send(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
init: { headers?: Record<string, string>; body?: BodyInit } = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = { ...init.headers };
|
||||||
|
|
||||||
if (body) {
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = this.config.getToken();
|
const token = this.config.getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -61,7 +57,7 @@ class ApiClient {
|
|||||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: init.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -77,6 +73,17 @@ class ApiClient {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetch(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
return this.send(method, path, {
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
schema: z.ZodType<T>,
|
schema: z.ZodType<T>,
|
||||||
method: string,
|
method: string,
|
||||||
@@ -137,6 +144,25 @@ class ApiClient {
|
|||||||
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Avatar ---
|
||||||
|
|
||||||
|
async updateAvatar(blob: Blob): Promise<void> {
|
||||||
|
await this.send('PUT', '/humans/me/avatar', {
|
||||||
|
headers: { 'Content-Type': blob.type || 'image/jpeg' },
|
||||||
|
body: blob,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAvatar(): Promise<void> {
|
||||||
|
await this.requestVoid('DELETE', '/humans/me/avatar');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvatarDownloadUrl(objectId: string): Promise<string> {
|
||||||
|
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Depot ---
|
// --- Depot ---
|
||||||
|
|
||||||
async prepareUpload(data: PrepareUploadRequest) {
|
async prepareUpload(data: PrepareUploadRequest) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
email_prefix: z.string(),
|
email_prefix: z.string(),
|
||||||
email_notifications_enabled: z.boolean(),
|
email_notifications_enabled: z.boolean(),
|
||||||
|
avatar_object_id: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Human = z.infer<typeof HumanSchema>;
|
export type Human = z.infer<typeof HumanSchema>;
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
interface AudioLevelBarsProps {
|
|
||||||
sourceNode: AudioNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BAR_COUNT = 3;
|
|
||||||
const MIN_HEIGHT_PX = 6;
|
|
||||||
const MAX_HEIGHT_PX = 48;
|
|
||||||
|
|
||||||
// dB scale
|
|
||||||
const NOISE_FLOOR_DB = -50;
|
|
||||||
const DB_RANGE = -NOISE_FLOOR_DB; // 50dB dynamic range
|
|
||||||
|
|
||||||
// Asymmetric smoothing time constants
|
|
||||||
const ATTACK_MS = 30;
|
|
||||||
const RELEASE_MS = 300;
|
|
||||||
|
|
||||||
// Bar activation thresholds on the 0..1 normalized dB scale
|
|
||||||
const BAR_THRESHOLDS = [0.0, 0.15, 0.35];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 3-bar VU meter that visualizes audio levels from any AudioNode source.
|
|
||||||
* Works with both live MediaStream sources and MediaElement sources.
|
|
||||||
*
|
|
||||||
* Uses direct DOM manipulation with exponential smoothing on a dB scale
|
|
||||||
* for smooth, jitter-free animation independent of frame rate.
|
|
||||||
*/
|
|
||||||
export function AudioLevelBars({ sourceNode }: AudioLevelBarsProps) {
|
|
||||||
const barRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const ctx = sourceNode.context as AudioContext;
|
|
||||||
const analyser = ctx.createAnalyser();
|
|
||||||
analyser.fftSize = 256;
|
|
||||||
sourceNode.connect(analyser);
|
|
||||||
|
|
||||||
// Connect to destination via silent gain node — without this,
|
|
||||||
// Chromium suspends processing on disconnected audio graphs.
|
|
||||||
const silentGain = ctx.createGain();
|
|
||||||
silentGain.gain.value = 0;
|
|
||||||
analyser.connect(silentGain);
|
|
||||||
silentGain.connect(ctx.destination);
|
|
||||||
|
|
||||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
||||||
|
|
||||||
let smoothedLevel = 0;
|
|
||||||
let lastTime = performance.now();
|
|
||||||
let rafId = 0;
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
const now = performance.now();
|
|
||||||
const dt = now - lastTime;
|
|
||||||
lastTime = now;
|
|
||||||
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
|
||||||
|
|
||||||
// Compute RMS of waveform (128 = silence baseline)
|
|
||||||
let sumSquares = 0;
|
|
||||||
for (let i = 0; i < dataArray.length; i++) {
|
|
||||||
const normalized = (dataArray[i] - 128) / 128;
|
|
||||||
sumSquares += normalized * normalized;
|
|
||||||
}
|
|
||||||
const rms = Math.sqrt(sumSquares / dataArray.length);
|
|
||||||
|
|
||||||
// Convert to dB, clamp to noise floor, normalize to 0..1
|
|
||||||
const db = rms > 0 ? 20 * Math.log10(rms) : NOISE_FLOOR_DB;
|
|
||||||
const normalizedDb = Math.max(0, (db - NOISE_FLOOR_DB) / DB_RANGE);
|
|
||||||
|
|
||||||
// Asymmetric exponential smoothing (frame-rate independent)
|
|
||||||
const timeConstant =
|
|
||||||
normalizedDb > smoothedLevel ? ATTACK_MS : RELEASE_MS;
|
|
||||||
const alpha = 1 - Math.exp(-dt / timeConstant);
|
|
||||||
smoothedLevel += alpha * (normalizedDb - smoothedLevel);
|
|
||||||
|
|
||||||
// Update bar heights via direct DOM writes
|
|
||||||
for (let i = 0; i < BAR_COUNT; i++) {
|
|
||||||
const el = barRefs.current[i];
|
|
||||||
if (!el) continue;
|
|
||||||
|
|
||||||
const threshold = BAR_THRESHOLDS[i];
|
|
||||||
const barLevel =
|
|
||||||
smoothedLevel <= threshold
|
|
||||||
? 0
|
|
||||||
: Math.min(1, (smoothedLevel - threshold) / (1 - threshold));
|
|
||||||
const height =
|
|
||||||
MIN_HEIGHT_PX + barLevel * (MAX_HEIGHT_PX - MIN_HEIGHT_PX);
|
|
||||||
el.style.height = `${height}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
try {
|
|
||||||
sourceNode.disconnect(analyser);
|
|
||||||
analyser.disconnect(silentGain);
|
|
||||||
silentGain.disconnect(ctx.destination);
|
|
||||||
} catch {
|
|
||||||
// Nodes may already be disconnected
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [sourceNode]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-end gap-1.5">
|
|
||||||
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
ref={(el) => {
|
|
||||||
barRefs.current[i] = el;
|
|
||||||
}}
|
|
||||||
className="w-1.5 rounded-full bg-green-400"
|
|
||||||
style={{ height: `${MIN_HEIGHT_PX}px` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface CenteredWaveformProps {
|
||||||
|
sourceNode: AudioNode;
|
||||||
|
/** Canvas size in CSS pixels. */
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
barWidth?: number;
|
||||||
|
gap?: number;
|
||||||
|
/** Bars are drawn with currentColor — set a text-* class to color them. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voice band fanned out from the center bar (lows) to the edges (highs).
|
||||||
|
const MIN_FREQ_HZ = 100;
|
||||||
|
const MAX_FREQ_HZ = 4500;
|
||||||
|
|
||||||
|
// Analyser dB range mapped onto bar height; the defaults (-100/-30) waste
|
||||||
|
// most of the range on inaudible levels.
|
||||||
|
const MIN_DB = -75;
|
||||||
|
const MAX_DB = -25;
|
||||||
|
|
||||||
|
// Per-bar asymmetric smoothing time constants.
|
||||||
|
const ATTACK_MS = 40;
|
||||||
|
const RELEASE_MS = 220;
|
||||||
|
|
||||||
|
// Edge bars keep a fraction of their response so the whole row stays alive
|
||||||
|
// instead of only the middle moving.
|
||||||
|
const EDGE_RESPONSE = 0.3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered live voice indicator, like a meeting app's "speaking" glyph.
|
||||||
|
* Bars fan out symmetrically from the middle: the center tracks the low
|
||||||
|
* frequencies where voice energy lives, the edges track the highs, and a
|
||||||
|
* cosine envelope tapers the response so the shape blooms from the center
|
||||||
|
* while someone speaks and settles into a dot line in silence.
|
||||||
|
*
|
||||||
|
* Renders on canvas with an rAF loop and no React state. No reduced-motion
|
||||||
|
* branch: the animation is signal-bearing audio feedback, not decoration,
|
||||||
|
* and only runs while audio is being captured or played.
|
||||||
|
*/
|
||||||
|
export function CenteredWaveform({
|
||||||
|
sourceNode,
|
||||||
|
width = 168,
|
||||||
|
height = 56,
|
||||||
|
barWidth = 4,
|
||||||
|
gap = 4,
|
||||||
|
className,
|
||||||
|
}: CenteredWaveformProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvasEl = canvasRef.current;
|
||||||
|
const ctx2d = canvasEl?.getContext('2d');
|
||||||
|
if (!canvasEl || !ctx2d) return;
|
||||||
|
// Rebind post-guard so the narrowed types carry into the closures below.
|
||||||
|
const canvas = canvasEl;
|
||||||
|
const drawCtx = ctx2d;
|
||||||
|
|
||||||
|
const { analyser, detach } = attachAnalyser(sourceNode, 512);
|
||||||
|
analyser.smoothingTimeConstant = 0.8;
|
||||||
|
analyser.minDecibels = MIN_DB;
|
||||||
|
analyser.maxDecibels = MAX_DB;
|
||||||
|
|
||||||
|
const bins = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
|
||||||
|
const stride = barWidth + gap;
|
||||||
|
// Odd count so one bar sits exactly at the center.
|
||||||
|
let barCount = Math.floor((width + gap) / stride);
|
||||||
|
if (barCount % 2 === 0) barCount -= 1;
|
||||||
|
const half = (barCount - 1) / 2;
|
||||||
|
|
||||||
|
// Map bar k (0 = center) to a frequency bin, log-spaced so the busy low
|
||||||
|
// end of the voice spectrum spreads across several bars.
|
||||||
|
const binHz = sourceNode.context.sampleRate / analyser.fftSize;
|
||||||
|
const binIndex = new Uint16Array(half + 1);
|
||||||
|
for (let k = 0; k <= half; k++) {
|
||||||
|
const freq =
|
||||||
|
MIN_FREQ_HZ *
|
||||||
|
Math.pow(MAX_FREQ_HZ / MIN_FREQ_HZ, half === 0 ? 0 : k / half);
|
||||||
|
binIndex[k] = Math.min(bins.length - 1, Math.round(freq / binHz));
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels = new Float32Array(half + 1);
|
||||||
|
|
||||||
|
const ensureBackingStore = createBackingStoreScaler(
|
||||||
|
canvas,
|
||||||
|
drawCtx,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
// currentColor, cached and refreshed periodically (theme switches show
|
||||||
|
// within 200ms) instead of paying a computed-style read every frame.
|
||||||
|
let fillColor = '';
|
||||||
|
let lastColorTime = 0;
|
||||||
|
let lastTime = performance.now();
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = performance.now();
|
||||||
|
const dt = now - lastTime;
|
||||||
|
lastTime = now;
|
||||||
|
|
||||||
|
analyser.getByteFrequencyData(bins);
|
||||||
|
|
||||||
|
for (let k = 0; k <= half; k++) {
|
||||||
|
const raw = bins[binIndex[k]] / 255;
|
||||||
|
const envelope =
|
||||||
|
half === 0
|
||||||
|
? 1
|
||||||
|
: EDGE_RESPONSE +
|
||||||
|
(1 - EDGE_RESPONSE) * Math.cos(((k / half) * Math.PI) / 2);
|
||||||
|
// Gamma keeps the floor quiet so silence reads as a dot line.
|
||||||
|
const target = Math.pow(raw, 1.4) * envelope;
|
||||||
|
|
||||||
|
// Asymmetric exponential smoothing (frame-rate independent)
|
||||||
|
const timeConstant = target > levels[k] ? ATTACK_MS : RELEASE_MS;
|
||||||
|
const alpha = 1 - Math.exp(-dt / timeConstant);
|
||||||
|
levels[k] += alpha * (target - levels[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensureBackingStore() || !fillColor || now - lastColorTime > 200) {
|
||||||
|
fillColor = getComputedStyle(canvas).color;
|
||||||
|
lastColorTime = now;
|
||||||
|
}
|
||||||
|
drawCtx.clearRect(0, 0, width, height);
|
||||||
|
drawCtx.fillStyle = fillColor;
|
||||||
|
|
||||||
|
const centerX = width / 2 - barWidth / 2;
|
||||||
|
drawCtx.beginPath();
|
||||||
|
for (let k = 0; k <= half; k++) {
|
||||||
|
const h = Math.max(barWidth, levels[k] * height);
|
||||||
|
const y = (height - h) / 2;
|
||||||
|
drawCtx.roundRect(centerX + k * stride, y, barWidth, h, barWidth / 2);
|
||||||
|
if (k > 0) {
|
||||||
|
drawCtx.roundRect(centerX - k * stride, y, barWidth, h, barWidth / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawCtx.fill();
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
detach();
|
||||||
|
};
|
||||||
|
}, [sourceNode, width, height, barWidth, gap]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas ref={canvasRef} className={className} style={{ width, height }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects an AnalyserNode to the source for visualization.
|
||||||
|
*
|
||||||
|
* The analyser is routed to the destination via a silent gain node — without
|
||||||
|
* this, Chromium suspends processing on disconnected audio graphs.
|
||||||
|
*/
|
||||||
|
function attachAnalyser(
|
||||||
|
sourceNode: AudioNode,
|
||||||
|
fftSize: number,
|
||||||
|
): { analyser: AnalyserNode; detach: () => void } {
|
||||||
|
const ctx = sourceNode.context;
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = fftSize;
|
||||||
|
sourceNode.connect(analyser);
|
||||||
|
|
||||||
|
const silentGain = ctx.createGain();
|
||||||
|
silentGain.gain.value = 0;
|
||||||
|
analyser.connect(silentGain);
|
||||||
|
silentGain.connect(ctx.destination);
|
||||||
|
|
||||||
|
return {
|
||||||
|
analyser,
|
||||||
|
detach() {
|
||||||
|
try {
|
||||||
|
sourceNode.disconnect(analyser);
|
||||||
|
analyser.disconnect(silentGain);
|
||||||
|
silentGain.disconnect(ctx.destination);
|
||||||
|
} catch {
|
||||||
|
// Nodes may already be disconnected
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the canvas backing store sized to width × height CSS pixels at the
|
||||||
|
* current devicePixelRatio, rescaling if the DPR changes (window moved
|
||||||
|
* between monitors). Call the returned function each frame; it returns true
|
||||||
|
* when the store was (re)initialized so callers can refresh cached state.
|
||||||
|
*/
|
||||||
|
function createBackingStoreScaler(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
drawCtx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): () => boolean {
|
||||||
|
let dpr = 0;
|
||||||
|
return () => {
|
||||||
|
const currentDpr = window.devicePixelRatio || 1;
|
||||||
|
if (currentDpr === dpr) return false;
|
||||||
|
dpr = currentDpr;
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
drawCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
|
|
||||||
interface ConfirmDestructiveOverlayProps {
|
interface ConfirmDestructiveOverlayProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -43,12 +44,14 @@ export function ConfirmDestructiveOverlay({
|
|||||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||||
<span className="text-xs text-white/30">
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys="Esc"
|
||||||
Esc
|
onClick={onClose}
|
||||||
</kbd>{' '}
|
title="Close (or press Esc)"
|
||||||
|
className="text-xs text-white/30"
|
||||||
|
>
|
||||||
to close
|
to close
|
||||||
</span>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-white/60">{description}</div>
|
<div className="text-sm text-white/60">{description}</div>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
|
|
||||||
|
interface HumanAvatarProps extends Omit<
|
||||||
|
React.ComponentProps<typeof Avatar>,
|
||||||
|
'children'
|
||||||
|
> {
|
||||||
|
/** Object id of the human's profile picture, if any. */
|
||||||
|
avatarObjectId?: string | null;
|
||||||
|
/** Initials rendered while loading or when no picture is set. */
|
||||||
|
initials: string;
|
||||||
|
/** Extra classes for the initials fallback. */
|
||||||
|
fallbackClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a human's avatar: their profile picture when set (resolved to a
|
||||||
|
* signed URL), otherwise their initials. The fallback also shows while the
|
||||||
|
* image loads or if it fails, so this is a drop-in for the initials-only
|
||||||
|
* <Avatar> usages throughout the app.
|
||||||
|
*/
|
||||||
|
export function HumanAvatar({
|
||||||
|
avatarObjectId,
|
||||||
|
initials,
|
||||||
|
fallbackClassName,
|
||||||
|
...props
|
||||||
|
}: HumanAvatarProps) {
|
||||||
|
const url = useAvatarUrl(avatarObjectId);
|
||||||
|
return (
|
||||||
|
<Avatar {...props}>
|
||||||
|
{url && <AvatarImage src={url} alt={initials} />}
|
||||||
|
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Kbd } from '@/components/ui/kbd';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */
|
||||||
|
const chipClass =
|
||||||
|
'rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
|
||||||
|
|
||||||
|
interface KeyHintProps {
|
||||||
|
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
|
||||||
|
keys: string | string[];
|
||||||
|
/** Rendered between chips, e.g. "or". Defaults to a plain space. */
|
||||||
|
separator?: React.ReactNode;
|
||||||
|
/** Text before the first chip, e.g. "Release". */
|
||||||
|
prefix?: React.ReactNode;
|
||||||
|
/** Trailing label, e.g. "cancel". May contain icons. */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
/** When set, renders a <button> with hover affordance; otherwise a plain <span>. */
|
||||||
|
onClick?: () => void;
|
||||||
|
/** Tooltip explaining the action; pass alongside onClick. */
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A keyboard-shortcut hint: one or more key chips with optional surrounding
|
||||||
|
* text. Keyboard-first, but every hint with an `onClick` is also a real
|
||||||
|
* button so mouse users can trigger the same action by clicking it.
|
||||||
|
*/
|
||||||
|
export function KeyHint({
|
||||||
|
keys,
|
||||||
|
separator,
|
||||||
|
prefix,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: KeyHintProps) {
|
||||||
|
const keyList = Array.isArray(keys) ? keys : [keys];
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{prefix != null && <>{prefix} </>}
|
||||||
|
{keyList.map((k, i) => (
|
||||||
|
<Fragment key={`${k}-${i}`}>
|
||||||
|
{i > 0 && (separator != null ? <> {separator} </> : ' ')}
|
||||||
|
<Kbd className={chipClass}>{k}</Kbd>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{children != null && <> {children}</>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onClick) {
|
||||||
|
return (
|
||||||
|
<span className={className} {...rest}>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
// Keep focus where it is (e.g. the compose textarea); the click still fires.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
title={title}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded transition-colors hover:text-white/80',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
|
|
||||||
export interface KeybindingEntry {
|
export interface KeybindingEntry {
|
||||||
keys: string[];
|
keys: string[];
|
||||||
@@ -54,16 +55,15 @@ export function KeybindingsOverlay({
|
|||||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||||
<span className="text-xs text-white/30">
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys={['Esc', '?']}
|
||||||
Esc
|
separator="or"
|
||||||
</kbd>{' '}
|
onClick={onClose}
|
||||||
or{' '}
|
title="Close (or press Esc / ?)"
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
className="text-xs text-white/30"
|
||||||
?
|
>
|
||||||
</kbd>{' '}
|
|
||||||
to close
|
to close
|
||||||
</span>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
@@ -80,16 +80,10 @@ export function KeybindingsOverlay({
|
|||||||
<span className="text-sm text-white/70">
|
<span className="text-sm text-white/70">
|
||||||
{binding.description}
|
{binding.description}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<KeyHint
|
||||||
{binding.keys.map((k) => (
|
keys={binding.keys}
|
||||||
<kbd
|
className="flex items-center gap-1 text-white/60"
|
||||||
key={k}
|
/>
|
||||||
className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs text-white/60"
|
|
||||||
>
|
|
||||||
{k}
|
|
||||||
</kbd>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn('inline-flex items-center gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Video, Mic } from 'lucide-react';
|
import { Video, Mic } from 'lucide-react';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||||
|
|
||||||
export function VideoAudioToggle() {
|
export function VideoAudioToggle() {
|
||||||
@@ -6,8 +7,8 @@ export function VideoAudioToggle() {
|
|||||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<KeyHint
|
||||||
role="button"
|
keys="V"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video')
|
setRecordingMode(recordingMode === 'video' ? 'audio' : 'video')
|
||||||
}
|
}
|
||||||
@@ -16,11 +17,7 @@ export function VideoAudioToggle() {
|
|||||||
? 'Switch to audio-only (V)'
|
? 'Switch to audio-only (V)'
|
||||||
: 'Switch to video (V)'
|
: 'Switch to video (V)'
|
||||||
}
|
}
|
||||||
className="cursor-pointer transition-colors hover:text-white/80"
|
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
V
|
|
||||||
</kbd>{' '}
|
|
||||||
{recordingMode === 'video' ? (
|
{recordingMode === 'video' ? (
|
||||||
<>
|
<>
|
||||||
<Video className="inline size-3" /> video
|
<Video className="inline size-3" /> video
|
||||||
@@ -30,6 +27,6 @@ export function VideoAudioToggle() {
|
|||||||
<Mic className="inline size-3" /> audio
|
<Mic className="inline size-3" /> audio
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</KeyHint>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||||
import { useObjectUrl } from '@/hooks/use-object-url';
|
import { useObjectUrl } from '@/hooks/use-object-url';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { platform } from '@/lib/platform';
|
import { platform } from '@/lib/platform';
|
||||||
|
|
||||||
@@ -294,37 +295,46 @@ export function AttachmentLightbox({
|
|||||||
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-4 text-xs text-white/50">
|
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-4 text-xs text-white/50">
|
||||||
{hasMultiple && (
|
{hasMultiple && (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<KeyHint
|
||||||
←
|
keys="←"
|
||||||
</kbd>
|
onClick={() => goTo(-1)}
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
title="Previous (or press ←)"
|
||||||
→
|
aria-label="Previous attachment"
|
||||||
</kbd>
|
/>
|
||||||
|
<KeyHint
|
||||||
|
keys="→"
|
||||||
|
onClick={() => goTo(1)}
|
||||||
|
title="Next (or press →)"
|
||||||
|
aria-label="Next attachment"
|
||||||
|
/>
|
||||||
navigate
|
navigate
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{canDownload && (
|
{canDownload && (
|
||||||
<span className="flex items-center gap-1.5">
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys="D"
|
||||||
D
|
onClick={handleDownload}
|
||||||
</kbd>
|
title="Download (or press D)"
|
||||||
|
>
|
||||||
download
|
download
|
||||||
</span>
|
</KeyHint>
|
||||||
)}
|
)}
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<span className="flex items-center gap-1.5">
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys="⌫"
|
||||||
⌫
|
onClick={handleRemove}
|
||||||
</kbd>
|
title="Remove (or press Backspace)"
|
||||||
|
>
|
||||||
remove
|
remove
|
||||||
</span>
|
</KeyHint>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1.5">
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys="Esc"
|
||||||
Esc
|
onClick={() => onOpenChange(null)}
|
||||||
</kbd>
|
title="Close (or press Esc)"
|
||||||
|
>
|
||||||
close
|
close
|
||||||
</span>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { particlePath, parseParticlePath } from '@/lib/particle-path';
|
|||||||
import type { ParticlePath } from '@/lib/particle-path';
|
import type { ParticlePath } from '@/lib/particle-path';
|
||||||
import { RecordingOverlay } from '@/features/compose/recording-overlay';
|
import { RecordingOverlay } from '@/features/compose/recording-overlay';
|
||||||
import { ScreenSourcePicker } from '@/components/screen-source-picker';
|
import { ScreenSourcePicker } from '@/components/screen-source-picker';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { TextComposeStep } from '@/features/compose/text-compose-step';
|
import { TextComposeStep } from '@/features/compose/text-compose-step';
|
||||||
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
|
import { ConfigureStreamStep } from '@/features/compose/configure-stream-step';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
@@ -726,28 +727,20 @@ export function ComposeOverlay({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
|
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys="S"
|
||||||
onClick={handleStopIntent}
|
onClick={handleStopIntent}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Stop screen recording (or press S)"
|
title="Stop screen recording (or press S)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
S
|
|
||||||
</kbd>{' '}
|
|
||||||
stop
|
stop
|
||||||
</button>
|
</KeyHint>
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys="Q"
|
||||||
onClick={handleCancelIntent}
|
onClick={handleCancelIntent}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Cancel screen recording (or press Q)"
|
title="Cancel screen recording (or press Q)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Q
|
|
||||||
</kbd>{' '}
|
|
||||||
cancel
|
cancel
|
||||||
</button>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { metaKey } from '@/lib/platform';
|
|||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { generateRandomName } from '@/lib/random-name';
|
import { generateRandomName } from '@/lib/random-name';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@@ -158,18 +159,16 @@ export function ConfigureStreamStep({
|
|||||||
|
|
||||||
{/* Keyboard hints */}
|
{/* Keyboard hints */}
|
||||||
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
|
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
|
||||||
<span>
|
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Esc
|
|
||||||
</kbd>{' '}
|
|
||||||
cancel
|
cancel
|
||||||
</span>
|
</KeyHint>
|
||||||
<span>
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys={`${metaKey}+Enter`}
|
||||||
{metaKey}+Enter
|
onClick={handleSubmit}
|
||||||
</kbd>{' '}
|
title={`Create stream (or press ${metaKey}+Enter)`}
|
||||||
|
>
|
||||||
create
|
create
|
||||||
</span>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Paperclip } from 'lucide-react';
|
import { Paperclip } from 'lucide-react';
|
||||||
import type { RecordingMode } from '@/hooks/use-recording-mode';
|
import type { RecordingMode } from '@/stores/media-settings-store';
|
||||||
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
|
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
||||||
import { useAudioSource } from '@/components/audio/use-audio-source';
|
import { useAudioSource } from '@/components/audio/use-audio-source';
|
||||||
import { useObjectUrl } from '@/hooks/use-object-url';
|
import { useObjectUrl } from '@/hooks/use-object-url';
|
||||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||||
|
|
||||||
interface RecordingOverlayProps {
|
interface RecordingOverlayProps {
|
||||||
@@ -67,7 +68,6 @@ function ReviewPlayback({
|
|||||||
objectFit?: 'cover' | 'contain';
|
objectFit?: 'cover' | 'contain';
|
||||||
}) {
|
}) {
|
||||||
const objectUrl = useObjectUrl(blob);
|
const objectUrl = useObjectUrl(blob);
|
||||||
const audioElRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||||
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
const audioSource = useAudioSource(isVideo ? null : audioEl);
|
||||||
|
|
||||||
@@ -87,17 +87,12 @@ function ReviewPlayback({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<audio
|
<audio ref={setAudioEl} src={objectUrl} autoPlay loop />
|
||||||
ref={(el) => {
|
|
||||||
audioElRef.current = el;
|
|
||||||
setAudioEl(el);
|
|
||||||
}}
|
|
||||||
src={objectUrl}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
/>
|
|
||||||
{audioSource ? (
|
{audioSource ? (
|
||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
<CenteredWaveform
|
||||||
|
sourceNode={audioSource.sourceNode}
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-white/60">Playing back audio...</span>
|
<span className="text-sm text-white/60">Playing back audio...</span>
|
||||||
)}
|
)}
|
||||||
@@ -198,43 +193,35 @@ export function RecordingOverlay({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom center: audio level bars (recording with active stream) */}
|
{/* Bottom center: live waveform (recording with active stream) */}
|
||||||
{isRecording && recordingAudioSource && (
|
{isRecording && recordingAudioSource && (
|
||||||
<div className="z-10 absolute bottom-15">
|
<div className="z-10 absolute bottom-15">
|
||||||
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
|
<CenteredWaveform
|
||||||
|
sourceNode={recordingAudioSource.sourceNode}
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom center: keyboard hints */}
|
{/* Bottom center: keyboard hints */}
|
||||||
{isRecording && !isLoading && (
|
{isRecording && !isLoading && (
|
||||||
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
|
<div className="absolute bottom-4 z-10 flex items-center gap-4 text-sm text-white/50">
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys="`"
|
||||||
|
prefix="Release"
|
||||||
onClick={() => requestIntent('stop')}
|
onClick={() => requestIntent('stop')}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Finish recording (or release `)"
|
title="Finish recording (or release `)"
|
||||||
>
|
>
|
||||||
Release{' '}
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
`
|
|
||||||
</kbd>{' '}
|
|
||||||
to review
|
to review
|
||||||
</button>
|
</KeyHint>
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys={['Esc', 'Q']}
|
||||||
|
separator="or"
|
||||||
onClick={() => requestIntent('cancel')}
|
onClick={() => requestIntent('cancel')}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Discard recording (or press Esc / Q)"
|
title="Discard recording (or press Esc / Q)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Esc
|
|
||||||
</kbd>
|
|
||||||
{' or '}
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Q
|
|
||||||
</kbd>{' '}
|
|
||||||
to cancel
|
to cancel
|
||||||
</button>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -250,32 +237,21 @@ export function RecordingOverlay({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys="Enter"
|
||||||
onClick={() => requestIntent('send')}
|
onClick={() => requestIntent('send')}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Send (or press Enter)"
|
title="Send (or press Enter)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Enter
|
|
||||||
</kbd>{' '}
|
|
||||||
next
|
next
|
||||||
</button>
|
</KeyHint>
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys={['Esc', 'Q']}
|
||||||
|
separator="or"
|
||||||
onClick={() => requestIntent('cancel')}
|
onClick={() => requestIntent('cancel')}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Discard (or press Esc / Q)"
|
title="Discard (or press Esc / Q)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Esc
|
|
||||||
</kbd>
|
|
||||||
{' or '}
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Q
|
|
||||||
</kbd>{' '}
|
|
||||||
to cancel
|
to cancel
|
||||||
</button>
|
</KeyHint>
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
|
|||||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { MarkdownEditor } from '@/features/compose/markdown-editor';
|
import { MarkdownEditor } from '@/features/compose/markdown-editor';
|
||||||
|
|
||||||
export interface TextEditorAttachmentProps {
|
export interface TextEditorAttachmentProps {
|
||||||
@@ -111,25 +112,26 @@ export function TextEditor({
|
|||||||
|
|
||||||
const keyboardHints = (
|
const keyboardHints = (
|
||||||
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
||||||
<span>
|
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Esc
|
|
||||||
</kbd>{' '}
|
|
||||||
cancel
|
cancel
|
||||||
</span>
|
</KeyHint>
|
||||||
<span>
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys={`${metaKey}+Enter`}
|
||||||
{metaKey}+Enter
|
onClick={() => {
|
||||||
</kbd>{' '}
|
if (textContent.trim()) onSubmit();
|
||||||
|
}}
|
||||||
|
title={`Submit (or press ${metaKey}+Enter)`}
|
||||||
|
>
|
||||||
{submitHint}
|
{submitHint}
|
||||||
</span>
|
</KeyHint>
|
||||||
{immersive && (
|
{immersive && (
|
||||||
<span>
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys={`${metaKey}+M`}
|
||||||
{metaKey}+M
|
onClick={() => setForceCardMode(true)}
|
||||||
</kbd>{' '}
|
title={`Switch to markdown editor (or press ${metaKey}+M)`}
|
||||||
|
>
|
||||||
markdown
|
markdown
|
||||||
</span>
|
</KeyHint>
|
||||||
)}
|
)}
|
||||||
{attachmentProps && (
|
{attachmentProps && (
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { RecordingMode } from '@/hooks/use-recording-mode';
|
import type { RecordingMode } from '@/stores/media-settings-store';
|
||||||
|
|
||||||
const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
|
const VIDEO_PREFERRED_MIME = 'video/webm;codecs=vp9,opus';
|
||||||
const VIDEO_FALLBACK_MIME = 'video/webm';
|
const VIDEO_FALLBACK_MIME = 'video/webm';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CircleDot, CircleCheckBig } from 'lucide-react';
|
|||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { ParticleListView } from '@/features/particles/particle-list-view';
|
import { ParticleListView } from '@/features/particles/particle-list-view';
|
||||||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { ComposeOverlay } from './compose/compose-overlay';
|
import { ComposeOverlay } from './compose/compose-overlay';
|
||||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||||
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
|
import { ComposeQuotaIndicator } from './compose/compose-quota-indicator';
|
||||||
@@ -103,44 +104,23 @@ function NetworkRootControls() {
|
|||||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||||
<span>
|
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<KeyHint keys="1–9">jump</KeyHint>
|
||||||
↑↓
|
|
||||||
</kbd>{' '}
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Enter
|
|
||||||
</kbd>{' '}
|
|
||||||
navigate
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
1–9
|
|
||||||
</kbd>{' '}
|
|
||||||
jump
|
|
||||||
</span>
|
|
||||||
<VideoAudioToggle />
|
<VideoAudioToggle />
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys="Hold `"
|
||||||
onClick={() => requestIntent('record')}
|
onClick={() => requestIntent('record')}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Start recording (or hold `)"
|
title="Start recording (or hold `)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Hold `
|
|
||||||
</kbd>{' '}
|
|
||||||
to start
|
to start
|
||||||
</button>
|
</KeyHint>
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys="T"
|
||||||
onClick={() => requestIntent('text')}
|
onClick={() => requestIntent('text')}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Compose text (or press T)"
|
title="Compose text (or press T)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
T
|
|
||||||
</kbd>{' '}
|
|
||||||
text
|
text
|
||||||
</button>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function CreateNetworkDialog({
|
|||||||
toast.success(`Created ${network.name}`);
|
toast.success(`Created ${network.name}`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setName('');
|
setName('');
|
||||||
navigate(`/${network.id}/settings`);
|
navigate(`/${network.id}/settings?section=members&add=1`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,38 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from 'lucide-react';
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CreditCard,
|
||||||
|
Mail,
|
||||||
|
Shield,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Muted } from '@/components/ui/typography';
|
import { Muted } from '@/components/ui/typography';
|
||||||
import { WindowControls } from '@/components/window-controls';
|
import { WindowControls } from '@/components/window-controls';
|
||||||
import { useNetworks } from '@/hooks/use-networks';
|
import { useNetworks } from '@/hooks/use-networks';
|
||||||
import {
|
import {
|
||||||
useNetworkInvitations,
|
useNetworkInvitations,
|
||||||
useInviteMembers,
|
|
||||||
useRevokeInvitation,
|
useRevokeInvitation,
|
||||||
useRemoveMember,
|
useRemoveMember,
|
||||||
} from '@/hooks/use-member-management';
|
} from '@/hooks/use-member-management';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { BillingSection } from '@/features/network-billing';
|
import { BillingSection } from '@/features/network-billing';
|
||||||
|
import { AddMembersDialog } from '@/features/network-settings/add-members-dialog';
|
||||||
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
|
import { ConfirmDestructiveOverlay } from '@/components/confirm-destructive-overlay';
|
||||||
import type { Human } from '@/api/types';
|
import type { Human } from '@/api/types';
|
||||||
|
|
||||||
|
type Section = 'members' | 'billing';
|
||||||
|
|
||||||
function MemberRow({
|
function MemberRow({
|
||||||
human,
|
human,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -35,11 +46,11 @@ function MemberRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||||
<Avatar>
|
<HumanAvatar
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
avatarObjectId={human.avatar_object_id}
|
||||||
{initials}
|
initials={initials}
|
||||||
</AvatarFallback>
|
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||||
</Avatar>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{human.email_prefix}</p>
|
<p className="truncate text-sm font-medium">{human.email_prefix}</p>
|
||||||
<Muted className="text-xs">{human.email}</Muted>
|
<Muted className="text-xs">{human.email}</Muted>
|
||||||
@@ -65,43 +76,6 @@ function MemberRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InviteForm({ networkId }: { networkId: string }) {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const inviteMembers = useInviteMembers(networkId);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const trimmed = email.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
|
|
||||||
inviteMembers.mutate([trimmed], {
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(`Invitation sent to ${trimmed}`);
|
|
||||||
setEmail('');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="[email protected]"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
disabled={!email.trim() || inviteMembers.isPending}
|
|
||||||
>
|
|
||||||
{inviteMembers.isPending ? 'Sending...' : 'Invite'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PendingInvitationRow({
|
function PendingInvitationRow({
|
||||||
email,
|
email,
|
||||||
networkId,
|
networkId,
|
||||||
@@ -141,36 +115,38 @@ function PendingInvitationRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({
|
function SectionHeading({
|
||||||
icon,
|
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
trailing,
|
count,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
trailing?: React.ReactNode;
|
count?: number;
|
||||||
|
action?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 px-4 pb-2 pt-6">
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
<span className="text-muted-foreground mt-0.5 flex size-4 items-center justify-center">
|
<div className="min-w-0">
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
|
||||||
{trailing}
|
{count != null && (
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{description && <Muted className="text-xs">{description}</Muted>}
|
{description && <Muted className="mt-0.5 text-xs">{description}</Muted>}
|
||||||
</div>
|
</div>
|
||||||
|
{action}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ children }: { children: React.ReactNode }) {
|
function Panel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
|
<section className="bg-card/40 overflow-hidden rounded-lg border">
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -181,7 +157,7 @@ export default function NetworkSettingsPage() {
|
|||||||
const { networkId } = useParams<{ networkId: string }>();
|
const { networkId } = useParams<{ networkId: string }>();
|
||||||
if (!networkId)
|
if (!networkId)
|
||||||
throw new Error('NetworkSettingsPage requires a :networkId route param');
|
throw new Error('NetworkSettingsPage requires a :networkId route param');
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
const network = networks?.find((n) => n.id === networkId);
|
const network = networks?.find((n) => n.id === networkId);
|
||||||
const { data: invitations, error: invitationsError } =
|
const { data: invitations, error: invitationsError } =
|
||||||
@@ -189,18 +165,35 @@ export default function NetworkSettingsPage() {
|
|||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const isAdmin = currentUser?.id === network?.admin_human.id;
|
const isAdmin = currentUser?.id === network?.admin_human.id;
|
||||||
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
|
const [memberToRemove, setMemberToRemove] = useState<Human | null>(null);
|
||||||
|
// Onboarding: opening settings with `?add=1` (e.g. right after creating a
|
||||||
|
// network) starts with the Add members dialog open. Non-admins never render
|
||||||
|
// the dialog, so the initial value is harmless for them.
|
||||||
|
const [addOpen, setAddOpen] = useState(() => searchParams.get('add') === '1');
|
||||||
const removeMember = useRemoveMember(networkId);
|
const removeMember = useRemoveMember(networkId);
|
||||||
|
|
||||||
const billingRef = useRef<HTMLDivElement>(null);
|
const section: Section =
|
||||||
|
searchParams.get('section') === 'billing' ? 'billing' : 'members';
|
||||||
|
|
||||||
|
const setSection = (value: string) => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (value === 'billing') next.set('section', value);
|
||||||
|
else next.delete('section');
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strip the one-shot `add` param so the dialog doesn't reopen on refresh or
|
||||||
|
// back navigation. The initial open state was already captured above.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get('section') === 'billing') {
|
if (searchParams.get('add') !== '1') return;
|
||||||
billingRef.current?.scrollIntoView({
|
setSearchParams(
|
||||||
behavior: 'smooth',
|
(prev) => {
|
||||||
block: 'start',
|
const next = new URLSearchParams(prev);
|
||||||
});
|
next.delete('add');
|
||||||
}
|
return next;
|
||||||
}, [searchParams]);
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}, [setSearchParams, searchParams]);
|
||||||
|
|
||||||
const networkName = network?.name ?? 'Network';
|
const networkName = network?.name ?? 'Network';
|
||||||
const memberCount = network?.humans.length ?? 0;
|
const memberCount = network?.humans.length ?? 0;
|
||||||
@@ -223,121 +216,136 @@ export default function NetworkSettingsPage() {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<Tabs
|
||||||
<div className="flex items-center gap-3 px-4 pb-4 pt-6">
|
value={section}
|
||||||
<Avatar size="lg">
|
onValueChange={setSection}
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
orientation="vertical"
|
||||||
{networkInitials}
|
className="min-h-0 flex-1 gap-0"
|
||||||
</AvatarFallback>
|
>
|
||||||
</Avatar>
|
<aside className="flex w-52 shrink-0 flex-col gap-4 border-r p-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center gap-3 px-1 pt-1">
|
||||||
<p className="truncate text-base font-semibold">{networkName}</p>
|
<Avatar>
|
||||||
<Muted className="text-xs">
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
{networkInitials}
|
||||||
{isAdmin ? " · You're an admin" : ''}
|
</AvatarFallback>
|
||||||
</Muted>
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold">{networkName}</p>
|
||||||
|
<Muted className="text-xs">
|
||||||
|
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||||
|
</Muted>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<TabsList variant="line" className="w-full gap-1">
|
||||||
|
<TabsTrigger value="members">
|
||||||
|
<Users />
|
||||||
|
Members
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="billing">
|
||||||
|
<CreditCard />
|
||||||
|
Plan & Billing
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<Section>
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<SectionHeader
|
<TabsContent value="members" className="p-4">
|
||||||
icon={<Users className="size-4" />}
|
<SectionHeading
|
||||||
title="Members"
|
title="Members"
|
||||||
description="People with access to this network."
|
description="People with access to this network."
|
||||||
trailing={
|
count={memberCount}
|
||||||
<Badge variant="secondary" className="tabular-nums">
|
action={
|
||||||
{memberCount}
|
isAdmin ? (
|
||||||
</Badge>
|
<Button
|
||||||
}
|
size="sm"
|
||||||
/>
|
className="shrink-0"
|
||||||
<Separator />
|
onClick={() => setAddOpen(true)}
|
||||||
{network?.humans.map((human, index) => {
|
>
|
||||||
const isRowAdmin = human.id === network.admin_human.id;
|
<UserPlus className="mr-1 size-3.5" />
|
||||||
const canRemove =
|
Add members
|
||||||
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
|
</Button>
|
||||||
return (
|
|
||||||
<div key={human.id}>
|
|
||||||
<MemberRow
|
|
||||||
human={human}
|
|
||||||
isAdmin={isRowAdmin}
|
|
||||||
onRemove={
|
|
||||||
canRemove ? () => setMemberToRemove(human) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{index < network.humans.length - 1 && (
|
|
||||||
<Separator className="mx-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{isAdmin && network && (
|
|
||||||
<Section>
|
|
||||||
<SectionHeader
|
|
||||||
icon={<Mail className="size-4" />}
|
|
||||||
title="Invitations"
|
|
||||||
description="Invite teammates by email. They'll get a link to join."
|
|
||||||
trailing={
|
|
||||||
pendingCount > 0 ? (
|
|
||||||
<Badge variant="secondary" className="tabular-nums">
|
|
||||||
{pendingCount} pending
|
|
||||||
</Badge>
|
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
<Panel>
|
||||||
<InviteForm networkId={networkId} />
|
{network?.humans.map((human, index) => {
|
||||||
{invitationsError && (
|
const isRowAdmin = human.id === network.admin_human.id;
|
||||||
<>
|
const canRemove =
|
||||||
<Separator />
|
isAdmin && !isRowAdmin && human.id !== currentUser?.id;
|
||||||
<p className="text-muted-foreground px-4 py-3 text-xs">
|
return (
|
||||||
Couldn't load pending invitations.
|
<div key={human.id}>
|
||||||
</p>
|
<MemberRow
|
||||||
</>
|
human={human}
|
||||||
)}
|
isAdmin={isRowAdmin}
|
||||||
{invitations && invitations.length > 0 && (
|
onRemove={
|
||||||
<>
|
canRemove ? () => setMemberToRemove(human) : undefined
|
||||||
<Separator />
|
}
|
||||||
<div className="px-4 pb-1 pt-3">
|
|
||||||
<Muted className="text-xs font-medium uppercase tracking-wider">
|
|
||||||
Pending
|
|
||||||
</Muted>
|
|
||||||
</div>
|
|
||||||
{invitations.map((inv, index) => (
|
|
||||||
<div key={inv.email}>
|
|
||||||
<PendingInvitationRow
|
|
||||||
email={inv.email}
|
|
||||||
networkId={networkId}
|
|
||||||
/>
|
/>
|
||||||
{index < invitations.length - 1 && (
|
{index < network.humans.length - 1 && (
|
||||||
<Separator className="mx-4" />
|
<Separator className="mx-4" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</>
|
})}
|
||||||
)}
|
</Panel>
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={billingRef}>
|
{isAdmin && (
|
||||||
<Section>
|
<div className="mt-6">
|
||||||
<SectionHeader
|
<SectionHeading
|
||||||
icon={<CreditCard className="size-4" />}
|
title="Pending invitations"
|
||||||
title="Billing"
|
description="Invites that haven't been accepted yet."
|
||||||
|
count={pendingCount > 0 ? pendingCount : undefined}
|
||||||
|
/>
|
||||||
|
{invitationsError ? (
|
||||||
|
<Panel>
|
||||||
|
<p className="text-muted-foreground px-4 py-3 text-xs">
|
||||||
|
Couldn't load pending invitations.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
) : invitations && invitations.length > 0 ? (
|
||||||
|
<Panel>
|
||||||
|
{invitations.map((inv, index) => (
|
||||||
|
<div key={inv.email}>
|
||||||
|
<PendingInvitationRow
|
||||||
|
email={inv.email}
|
||||||
|
networkId={networkId}
|
||||||
|
/>
|
||||||
|
{index < invitations.length - 1 && (
|
||||||
|
<Separator className="mx-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<Muted className="text-xs">No pending invitations.</Muted>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="billing" className="p-4">
|
||||||
|
<SectionHeading
|
||||||
|
title="Plan & Billing"
|
||||||
description={
|
description={
|
||||||
isAdmin
|
isAdmin
|
||||||
? 'Manage your plan, seats, and payment.'
|
? 'Manage your plan, seats, and payment.'
|
||||||
: "Your network's current plan and usage."
|
: "Your network's current plan and usage."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Separator />
|
<Panel>
|
||||||
<BillingSection networkId={networkId} />
|
<BillingSection networkId={networkId} />
|
||||||
</Section>
|
</Panel>
|
||||||
</div>
|
</TabsContent>
|
||||||
|
</ScrollArea>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<div className="h-6" />
|
{isAdmin && (
|
||||||
</ScrollArea>
|
<AddMembersDialog
|
||||||
|
networkId={networkId}
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{memberToRemove && (
|
{memberToRemove && (
|
||||||
<ConfirmDestructiveOverlay
|
<ConfirmDestructiveOverlay
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Muted } from '@/components/ui/typography';
|
||||||
|
import { useInviteMembers } from '@/hooks/use-member-management';
|
||||||
|
|
||||||
|
const emailSchema = z.string().email();
|
||||||
|
|
||||||
|
function EmailChip({
|
||||||
|
email,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md py-0.5 pl-2 pr-1 text-xs">
|
||||||
|
{email}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label={`Remove ${email}`}
|
||||||
|
className="text-muted-foreground hover:text-foreground size-5"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddMembersDialog({
|
||||||
|
networkId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
networkId: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [emails, setEmails] = useState<string[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inviteMembers = useInviteMembers(networkId);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setEmails([]);
|
||||||
|
setInput('');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
if (!next) reset();
|
||||||
|
onOpenChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Commits the current input as a chip. Returns the next list of emails so
|
||||||
|
// callers (like submit) can act on the freshly-committed value.
|
||||||
|
const commit = (raw: string): string[] | null => {
|
||||||
|
const trimmed = raw.trim().replace(/,$/, '').trim();
|
||||||
|
if (!trimmed) return emails;
|
||||||
|
if (!emailSchema.safeParse(trimmed).success) {
|
||||||
|
setError(`"${trimmed}" doesn't look like a valid email.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (emails.includes(trimmed)) {
|
||||||
|
setInput('');
|
||||||
|
return emails;
|
||||||
|
}
|
||||||
|
const next = [...emails, trimmed];
|
||||||
|
setEmails(next);
|
||||||
|
setInput('');
|
||||||
|
setError(null);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(input);
|
||||||
|
} else if (e.key === 'Backspace' && input === '' && emails.length > 0) {
|
||||||
|
setEmails(emails.slice(0, -1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = commit(input);
|
||||||
|
if (next === null) return; // invalid pending input
|
||||||
|
if (next.length === 0) return;
|
||||||
|
|
||||||
|
inviteMembers.mutate(next, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(
|
||||||
|
next.length === 1
|
||||||
|
? `Invited ${next[0]}`
|
||||||
|
: `Invited ${next.length} people`,
|
||||||
|
);
|
||||||
|
handleOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add members</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter email addresses to add people to this network.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="border-input focus-within:border-ring focus-within:ring-ring/50 flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-1.5 transition-colors focus-within:ring-[3px]">
|
||||||
|
{emails.map((email) => (
|
||||||
|
<EmailChip
|
||||||
|
key={email}
|
||||||
|
email={email}
|
||||||
|
onRemove={() => setEmails(emails.filter((x) => x !== email))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInput(e.target.value);
|
||||||
|
if (error) setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => commit(input)}
|
||||||
|
placeholder={
|
||||||
|
emails.length === 0 ? '[email protected]' : 'Add another…'
|
||||||
|
}
|
||||||
|
className="h-7 min-w-[8rem] flex-1 border-0 px-1 shadow-none focus-visible:ring-0"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-destructive mt-1.5 text-xs">{error}</p>
|
||||||
|
) : (
|
||||||
|
<Muted className="mt-1.5 text-xs">
|
||||||
|
Press Enter or comma to add each email.
|
||||||
|
</Muted>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
inviteMembers.isPending ||
|
||||||
|
(emails.length === 0 && input.trim() === '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{inviteMembers.isPending ? 'Adding…' : 'Add members'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { useDownloadUrl } from '@/hooks/use-download-url';
|
|||||||
import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
|
import { useTranscriptPlayback } from '@/hooks/use-transcript-playback';
|
||||||
import { TranscriptOverlay } from '@/features/particles/transcript-overlay';
|
import { TranscriptOverlay } from '@/features/particles/transcript-overlay';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
|
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
||||||
import { useAudioSource } from '@/components/audio/use-audio-source';
|
import { useAudioSource } from '@/components/audio/use-audio-source';
|
||||||
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
import { useParticleAttachments } from '@/hooks/use-particle-attachments';
|
||||||
import { ParticleAttachments } from '@/features/particles/particle-attachments';
|
import { ParticleAttachments } from '@/features/particles/particle-attachments';
|
||||||
@@ -89,7 +89,7 @@ export const MediaParticleView = forwardRef<
|
|||||||
currentTime,
|
currentTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
|
// useAudioSource needs the audio element, but audioRef is only set after mount
|
||||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||||
const audioSource = useAudioSource(audioEl);
|
const audioSource = useAudioSource(audioEl);
|
||||||
|
|
||||||
@@ -156,7 +156,10 @@ export const MediaParticleView = forwardRef<
|
|||||||
|
|
||||||
{audioSource && (
|
{audioSource && (
|
||||||
<div className="z-10 absolute bottom-20">
|
<div className="z-10 absolute bottom-20">
|
||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
<CenteredWaveform
|
||||||
|
sourceNode={audioSource.sourceNode}
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { useAuthStore } from '@/stores/auth-store';
|
|||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Small } from '@/components/ui/typography';
|
import { Small } from '@/components/ui/typography';
|
||||||
@@ -150,7 +150,7 @@ const StreamRow = memo(function StreamRow({
|
|||||||
particle.visible_to.length === 2 &&
|
particle.visible_to.length === 2 &&
|
||||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
const avatar = useMemo(() => {
|
||||||
if (isDM) {
|
if (isDM) {
|
||||||
const otherEntry = particle.visible_to.find(
|
const otherEntry = particle.visible_to.find(
|
||||||
(v) => v !== `human:${userId}`,
|
(v) => v !== `human:${userId}`,
|
||||||
@@ -158,7 +158,12 @@ const StreamRow = memo(function StreamRow({
|
|||||||
if (otherEntry) {
|
if (otherEntry) {
|
||||||
const otherId = otherEntry.replace('human:', '');
|
const otherId = otherEntry.replace('human:', '');
|
||||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||||
if (otherHuman) return getInitials(otherHuman.email);
|
if (otherHuman) {
|
||||||
|
return {
|
||||||
|
initials: getInitials(otherHuman.email),
|
||||||
|
avatarObjectId: otherHuman.avatar_object_id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +171,18 @@ const StreamRow = memo(function StreamRow({
|
|||||||
const creator = network?.humans?.find(
|
const creator = network?.humans?.find(
|
||||||
(h) => h.id === latestChild.created_by_human_id,
|
(h) => h.id === latestChild.created_by_human_id,
|
||||||
);
|
);
|
||||||
if (creator) return getInitials(creator.email);
|
if (creator) {
|
||||||
|
return {
|
||||||
|
initials: getInitials(creator.email),
|
||||||
|
avatarObjectId: creator.avatar_object_id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
return {
|
||||||
|
initials: particle.properties.name.slice(0, 2).toUpperCase(),
|
||||||
|
avatarObjectId: null,
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
isDM,
|
isDM,
|
||||||
particle.visible_to,
|
particle.visible_to,
|
||||||
@@ -242,11 +255,12 @@ const StreamRow = memo(function StreamRow({
|
|||||||
{videoThumbObjectId ? (
|
{videoThumbObjectId ? (
|
||||||
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||||
) : (
|
) : (
|
||||||
<Avatar className={cn(isUnseen && 'ring-2 ring-primary')}>
|
<HumanAvatar
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
className={cn(isUnseen && 'ring-2 ring-primary')}
|
||||||
{initials}
|
avatarObjectId={avatar.avatarObjectId}
|
||||||
</AvatarFallback>
|
initials={avatar.initials}
|
||||||
</Avatar>
|
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -161,18 +161,16 @@ function SegmentPresenceAvatars({
|
|||||||
{visible.map((human) => (
|
{visible.map((human) => (
|
||||||
<Tooltip key={human.humanId}>
|
<Tooltip key={human.humanId}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Avatar
|
<HumanAvatar
|
||||||
size="xs"
|
size="xs"
|
||||||
className={
|
className={
|
||||||
onlineHumanIds?.has(human.humanId)
|
onlineHumanIds?.has(human.humanId)
|
||||||
? 'ring-2 ring-green-500'
|
? 'ring-2 ring-green-500'
|
||||||
: 'ring-1 ring-black/50'
|
: 'ring-1 ring-black/50'
|
||||||
}
|
}
|
||||||
>
|
avatarObjectId={human.avatarObjectId}
|
||||||
<AvatarFallback>
|
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||||
{human.emailPrefix.slice(0, 2).toUpperCase()}
|
/>
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="text-xs">
|
<TooltipContent side="top" className="text-xs">
|
||||||
{human.email}
|
{human.email}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
@@ -113,11 +113,13 @@ export function ReactionBar({
|
|||||||
: 'bg-black/40 hover:bg-black/50',
|
: 'bg-black/40 hover:bg-black/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar size="xs" className="shrink-0">
|
<HumanAvatar
|
||||||
<AvatarFallback className="bg-white/15 text-[9px] font-medium text-white">
|
size="xs"
|
||||||
{firstReactor.initials}
|
className="shrink-0"
|
||||||
</AvatarFallback>
|
avatarObjectId={firstReactor.avatarObjectId}
|
||||||
</Avatar>
|
initials={firstReactor.initials}
|
||||||
|
fallbackClassName="bg-white/15 text-[9px] font-medium text-white"
|
||||||
|
/>
|
||||||
<span className="truncate text-white/90">{text}</span>
|
<span className="truncate text-white/90">{text}</span>
|
||||||
{reactors.length > 1 && (
|
{reactors.length > 1 && (
|
||||||
<span className="shrink-0 text-white/60">
|
<span className="shrink-0 text-white/60">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { updateParticleProperties } from '@/lib/firestore-particles';
|
import { updateParticleProperties } from '@/lib/firestore-particles';
|
||||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
@@ -63,12 +64,14 @@ export function RenameStreamOverlay({
|
|||||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
|
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
|
||||||
<span className="text-xs text-white/30">
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys="Esc"
|
||||||
Esc
|
onClick={onClose}
|
||||||
</kbd>{' '}
|
title="Close (or press Esc)"
|
||||||
|
className="text-xs text-white/30"
|
||||||
|
>
|
||||||
to close
|
to close
|
||||||
</span>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import { forwardRef, useMemo } from 'react';
|
|
||||||
import { Headphones } from 'lucide-react';
|
|
||||||
import { cn, getInitials } from '@/lib/utils';
|
|
||||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
|
||||||
import { particlePath } from '@/lib/particle-path';
|
|
||||||
import type { Particle, StreamProperties } from '@/api/types';
|
|
||||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
|
||||||
import { ParticlePreview } from '@/features/particles/particle-preview';
|
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
||||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
|
||||||
import { Small } from '@/components/ui/typography';
|
|
||||||
|
|
||||||
interface StreamCardProps {
|
|
||||||
particle: Particle & { type: 'stream'; properties: StreamProperties };
|
|
||||||
networkId: string;
|
|
||||||
onClick: () => void;
|
|
||||||
isSelected?: boolean;
|
|
||||||
shortcutKey?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(
|
|
||||||
function StreamCard(
|
|
||||||
{ particle, networkId, onClick, isSelected, shortcutKey },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const streamPath = particlePath(networkId, [particle.id]);
|
|
||||||
const { latestChild } = useLiveLatestChild(streamPath);
|
|
||||||
const userId = useAuthStore((s) => s.user?.id) ?? '';
|
|
||||||
const network = useNetwork(networkId);
|
|
||||||
|
|
||||||
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
|
|
||||||
|
|
||||||
const hasActiveHuddle =
|
|
||||||
particle.huddle_active_participants &&
|
|
||||||
particle.huddle_active_participants.length > 0;
|
|
||||||
const huddleCount = particle.huddle_active_participants?.length ?? 0;
|
|
||||||
|
|
||||||
const isDM =
|
|
||||||
particle.visible_to.length === 2 &&
|
|
||||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
|
||||||
if (isDM) {
|
|
||||||
const otherEntry = particle.visible_to.find(
|
|
||||||
(v) => v !== `human:${userId}`,
|
|
||||||
);
|
|
||||||
if (otherEntry) {
|
|
||||||
const otherId = otherEntry.replace('human:', '');
|
|
||||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
|
||||||
if (otherHuman) return getInitials(otherHuman.email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latestChild) {
|
|
||||||
const creator = network?.humans?.find(
|
|
||||||
(h) => h.id === latestChild.created_by_human_id,
|
|
||||||
);
|
|
||||||
if (creator) return getInitials(creator.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
|
||||||
}, [
|
|
||||||
isDM,
|
|
||||||
particle.visible_to,
|
|
||||||
particle.properties.name,
|
|
||||||
userId,
|
|
||||||
latestChild,
|
|
||||||
network,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isUnseen = useMemo(() => {
|
|
||||||
if (!latestChild) return false;
|
|
||||||
const latestChildTimestamp = latestChild.created_at.getTime();
|
|
||||||
const userPlaybackPosition =
|
|
||||||
particle.playback_markers?.[userId]?.getTime() ?? 0;
|
|
||||||
return latestChildTimestamp > userPlaybackPosition;
|
|
||||||
}, [latestChild, particle.playback_markers, userId]);
|
|
||||||
|
|
||||||
// For media particles with a transcript, show it as an overlay on the preview
|
|
||||||
const transcript =
|
|
||||||
latestChild?.type === 'media'
|
|
||||||
? latestChild.properties.transcript?.transcript
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') onClick();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20',
|
|
||||||
isUnseen && 'ring-2 ring-primary',
|
|
||||||
isSelected && 'ring-2 ring-ring',
|
|
||||||
hasActiveHuddle && 'ring-2 ring-red-500/70',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Preview area */}
|
|
||||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
|
|
||||||
{hasActiveHuddle && (
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-red-500/15 to-transparent" />
|
|
||||||
)}
|
|
||||||
{shortcutKey && (
|
|
||||||
<kbd className="absolute top-1.5 left-1.5 z-10 flex size-5 items-center justify-center rounded bg-black/50 font-mono text-xs text-white/70">
|
|
||||||
{shortcutKey}
|
|
||||||
</kbd>
|
|
||||||
)}
|
|
||||||
{latestChild ? (
|
|
||||||
<ParticlePreview particle={latestChild} />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<p className="text-muted-foreground text-xs italic">
|
|
||||||
No messages yet
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Transcript overlay for media with transcripts */}
|
|
||||||
{transcript && (
|
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
|
|
||||||
<p className="line-clamp-2 text-md leading-snug text-white/90">
|
|
||||||
{transcript}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info bar */}
|
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
|
||||||
<Avatar
|
|
||||||
className={cn('size-6 shrink-0', isUnseen && 'ring-2 ring-primary')}
|
|
||||||
>
|
|
||||||
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
|
|
||||||
{initials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<Small
|
|
||||||
className={cn(
|
|
||||||
'min-w-0 truncate',
|
|
||||||
isUnseen
|
|
||||||
? 'font-semibold text-foreground'
|
|
||||||
: 'font-medium text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{particle.properties.name}
|
|
||||||
</Small>
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
||||||
{hasActiveHuddle && (
|
|
||||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
|
||||||
<Headphones className="size-3 text-red-400" />
|
|
||||||
<span className="text-[10px] font-medium text-red-400">
|
|
||||||
{huddleCount}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{latestChild && (
|
|
||||||
<Small
|
|
||||||
className={cn(
|
|
||||||
'text-[10px]',
|
|
||||||
isUnseen ? 'text-primary' : 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RelativeTimestamp date={latestChild.created_at} />
|
|
||||||
</Small>
|
|
||||||
)}
|
|
||||||
{isUnseen && (
|
|
||||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
|
import { X, UserPlus, Globe, Users, Lock } from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import {
|
import {
|
||||||
buildCustomVisibility,
|
buildCustomVisibility,
|
||||||
buildNetworkVisibility,
|
buildNetworkVisibility,
|
||||||
@@ -104,12 +105,14 @@ export function StreamMembersOverlay({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">Members</h2>
|
<h2 className="text-sm font-semibold text-white/70">Members</h2>
|
||||||
<span className="text-xs text-white/30">
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys="Esc"
|
||||||
Esc
|
onClick={onClose}
|
||||||
</kbd>{' '}
|
title="Close (or press Esc)"
|
||||||
|
className="text-xs text-white/30"
|
||||||
|
>
|
||||||
to close
|
to close
|
||||||
</span>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visibility */}
|
{/* Visibility */}
|
||||||
@@ -167,11 +170,12 @@ export function StreamMembersOverlay({
|
|||||||
key={id}
|
key={id}
|
||||||
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
|
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
|
||||||
>
|
>
|
||||||
<Avatar size="sm">
|
<HumanAvatar
|
||||||
<AvatarFallback className="text-[10px]">
|
size="sm"
|
||||||
{display.initials}
|
avatarObjectId={display.avatarObjectId}
|
||||||
</AvatarFallback>
|
initials={display.initials}
|
||||||
</Avatar>
|
fallbackClassName="text-[10px]"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 truncate',
|
'flex-1 truncate',
|
||||||
@@ -222,11 +226,12 @@ export function StreamMembersOverlay({
|
|||||||
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
|
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar size="sm">
|
<HumanAvatar
|
||||||
<AvatarFallback className="text-[10px]">
|
size="sm"
|
||||||
{getInitials(human.email)}
|
avatarObjectId={human.avatar_object_id}
|
||||||
</AvatarFallback>
|
initials={getInitials(human.email)}
|
||||||
</Avatar>
|
fallbackClassName="text-[10px]"
|
||||||
|
/>
|
||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
{human.email_prefix}
|
{human.email_prefix}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useAuthStore } from '@/stores/auth-store';
|
|||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { isParticleDeleted, type Particle } from '@/api/types';
|
import { isParticleDeleted, type Particle } from '@/api/types';
|
||||||
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||||
import { Avatar, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
import { AvatarGroup } from '@/components/ui/avatar';
|
||||||
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -147,11 +148,12 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
|||||||
return (
|
return (
|
||||||
<Tooltip key={humanId}>
|
<Tooltip key={humanId}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Avatar size="sm">
|
<HumanAvatar
|
||||||
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
|
size="sm"
|
||||||
{display.initials}
|
avatarObjectId={display.avatarObjectId}
|
||||||
</AvatarFallback>
|
initials={display.initials}
|
||||||
</Avatar>
|
fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
|
||||||
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{display.email}</TooltipContent>
|
<TooltipContent>{display.email}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -313,11 +315,13 @@ function MembersIndicator({
|
|||||||
<>
|
<>
|
||||||
<AvatarGroup>
|
<AvatarGroup>
|
||||||
{shownMembers.map((human) => (
|
{shownMembers.map((human) => (
|
||||||
<Avatar key={human.id} size="sm">
|
<HumanAvatar
|
||||||
<AvatarFallback className="text-[8px]">
|
key={human.id}
|
||||||
{resolveHumanDisplay(human.id, humans).initials}
|
size="sm"
|
||||||
</AvatarFallback>
|
avatarObjectId={human.avatar_object_id}
|
||||||
</Avatar>
|
initials={resolveHumanDisplay(human.id, humans).initials}
|
||||||
|
fallbackClassName="text-[8px]"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
@@ -355,9 +359,12 @@ function ParticleBreadcrumbContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Avatar size="sm" className={isOnline ? 'ring-2 ring-green-500' : ''}>
|
<HumanAvatar
|
||||||
<AvatarFallback>{display.initials}</AvatarFallback>
|
size="sm"
|
||||||
</Avatar>
|
className={isOnline ? 'ring-2 ring-green-500' : ''}
|
||||||
|
avatarObjectId={display.avatarObjectId}
|
||||||
|
initials={display.initials}
|
||||||
|
/>
|
||||||
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
{display.displayName} - <RelativeTimestamp date={particle.created_at} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { TextParticleView } from '@/features/particles/text-particle-view';
|
|||||||
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
|
import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
|
||||||
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
|
import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
|
||||||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||||
import {
|
import {
|
||||||
KeybindingsOverlay,
|
KeybindingsOverlay,
|
||||||
@@ -259,12 +260,17 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
const { fastPlayback } = usePlaybackKeys({ mediaRef });
|
const { fastPlayback } = usePlaybackKeys({ mediaRef });
|
||||||
|
|
||||||
|
const handleExitNavigate = useCallback(() => {
|
||||||
|
navigate(`/${networkId}`);
|
||||||
|
}, [navigate, networkId]);
|
||||||
|
|
||||||
useStreamNavigationKeys({
|
useStreamNavigationKeys({
|
||||||
next,
|
next,
|
||||||
prev,
|
prev,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
childrenLength: children.length,
|
childrenLength: children.length,
|
||||||
mediaRef,
|
mediaRef,
|
||||||
|
onExit: handleExitNavigate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenHuddle = useCallback(() => {
|
const handleOpenHuddle = useCallback(() => {
|
||||||
@@ -325,10 +331,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
// Always show controls when compose is active or exit countdown is visible
|
// Always show controls when compose is active or exit countdown is visible
|
||||||
const controlsVisible = showControls || composeActive || status === 'ended';
|
const controlsVisible = showControls || composeActive || status === 'ended';
|
||||||
|
|
||||||
const handleExitNavigate = useCallback(() => {
|
|
||||||
navigate(`/${networkId}`);
|
|
||||||
}, [navigate, networkId]);
|
|
||||||
|
|
||||||
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
|
const exitRemainingMs = useExitCountdown(status, paused, handleExitNavigate);
|
||||||
|
|
||||||
// Reset progress when the particle changes.
|
// Reset progress when the particle changes.
|
||||||
@@ -362,6 +364,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
<StreamViewControls
|
<StreamViewControls
|
||||||
showEscape
|
showEscape
|
||||||
onOpenKeybindings={() => setShowKeybindings(true)}
|
onOpenKeybindings={() => setShowKeybindings(true)}
|
||||||
|
onOpenHuddle={handleOpenHuddle}
|
||||||
|
onExit={handleExitNavigate}
|
||||||
/>
|
/>
|
||||||
<ComposeOverlay
|
<ComposeOverlay
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
@@ -505,6 +509,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
onlineHumanIds={onlineHumanIds}
|
onlineHumanIds={onlineHumanIds}
|
||||||
exitRemainingMs={exitRemainingMs}
|
exitRemainingMs={exitRemainingMs}
|
||||||
onOpenKeybindings={() => setShowKeybindings(true)}
|
onOpenKeybindings={() => setShowKeybindings(true)}
|
||||||
|
onOpenHuddle={handleOpenHuddle}
|
||||||
|
onExit={handleExitNavigate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KeybindingsOverlay
|
<KeybindingsOverlay
|
||||||
@@ -527,6 +533,8 @@ function BottomBar({
|
|||||||
onlineHumanIds,
|
onlineHumanIds,
|
||||||
exitRemainingMs,
|
exitRemainingMs,
|
||||||
onOpenKeybindings,
|
onOpenKeybindings,
|
||||||
|
onOpenHuddle,
|
||||||
|
onExit,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -540,6 +548,8 @@ function BottomBar({
|
|||||||
onlineHumanIds: Set<string>;
|
onlineHumanIds: Set<string>;
|
||||||
exitRemainingMs: number | null;
|
exitRemainingMs: number | null;
|
||||||
onOpenKeybindings: () => void;
|
onOpenKeybindings: () => void;
|
||||||
|
onOpenHuddle: () => void;
|
||||||
|
onExit: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -580,6 +590,8 @@ function BottomBar({
|
|||||||
<StreamViewControls
|
<StreamViewControls
|
||||||
showEscape
|
showEscape
|
||||||
onOpenKeybindings={onOpenKeybindings}
|
onOpenKeybindings={onOpenKeybindings}
|
||||||
|
onOpenHuddle={onOpenHuddle}
|
||||||
|
onExit={onExit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -590,58 +602,54 @@ function BottomBar({
|
|||||||
function StreamViewControls({
|
function StreamViewControls({
|
||||||
showEscape,
|
showEscape,
|
||||||
onOpenKeybindings,
|
onOpenKeybindings,
|
||||||
|
onOpenHuddle,
|
||||||
|
onExit,
|
||||||
}: {
|
}: {
|
||||||
showEscape?: boolean;
|
showEscape?: boolean;
|
||||||
onOpenKeybindings: () => void;
|
onOpenKeybindings: () => void;
|
||||||
|
onOpenHuddle: () => void;
|
||||||
|
onExit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||||
{showEscape && (
|
{showEscape && (
|
||||||
<span>
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys="Esc"
|
||||||
Esc
|
onClick={onExit}
|
||||||
</kbd>{' '}
|
title="Back to network (or press Esc)"
|
||||||
|
>
|
||||||
back
|
back
|
||||||
</span>
|
</KeyHint>
|
||||||
)}
|
)}
|
||||||
<VideoAudioToggle />
|
<VideoAudioToggle />
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys="Hold `"
|
||||||
onClick={() => requestIntent('record')}
|
onClick={() => requestIntent('record')}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Reply with a recording (or hold `)"
|
title="Reply with a recording (or hold `)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Hold `
|
|
||||||
</kbd>{' '}
|
|
||||||
to reply
|
to reply
|
||||||
</button>
|
</KeyHint>
|
||||||
<button
|
<KeyHint
|
||||||
type="button"
|
keys="T"
|
||||||
onClick={() => requestIntent('text')}
|
onClick={() => requestIntent('text')}
|
||||||
className="cursor-pointer rounded transition-colors hover:text-white/80"
|
|
||||||
title="Reply with text (or press T)"
|
title="Reply with text (or press T)"
|
||||||
>
|
>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
T
|
|
||||||
</kbd>{' '}
|
|
||||||
text
|
text
|
||||||
</button>
|
</KeyHint>
|
||||||
<span>
|
<KeyHint
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
keys="H"
|
||||||
H
|
onClick={onOpenHuddle}
|
||||||
</kbd>{' '}
|
title="Start a huddle (or press H)"
|
||||||
huddle
|
|
||||||
</span>
|
|
||||||
<kbd
|
|
||||||
role="button"
|
|
||||||
onClick={onOpenKeybindings}
|
|
||||||
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
|
|
||||||
title="Show all shortcuts"
|
|
||||||
>
|
>
|
||||||
?
|
huddle
|
||||||
</kbd>
|
</KeyHint>
|
||||||
|
<KeyHint
|
||||||
|
keys="?"
|
||||||
|
onClick={onOpenKeybindings}
|
||||||
|
title="Show all shortcuts"
|
||||||
|
aria-label="Show keyboard shortcuts"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Volume2,
|
Volume2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Camera,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
|
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { WindowControls } from '@/components/window-controls';
|
import { WindowControls } from '@/components/window-controls';
|
||||||
@@ -88,6 +90,7 @@ export default function SettingsPage() {
|
|||||||
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
|
const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled);
|
||||||
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
|
const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled);
|
||||||
const [version, setVersion] = useState<string>();
|
const [version, setVersion] = useState<string>();
|
||||||
|
const [avatarOpen, setAvatarOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.app.getVersion().then(setVersion);
|
platform.app.getVersion().then(setVersion);
|
||||||
@@ -135,17 +138,30 @@ export default function SettingsPage() {
|
|||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
{/* Profile header */}
|
{/* Profile header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-5">
|
<div className="flex items-center gap-3 px-4 py-5">
|
||||||
<Avatar size="lg">
|
<button
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
type="button"
|
||||||
{initials}
|
onClick={() => setAvatarOpen(true)}
|
||||||
</AvatarFallback>
|
className="group relative rounded-full"
|
||||||
</Avatar>
|
aria-label="Change profile picture"
|
||||||
|
>
|
||||||
|
<HumanAvatar
|
||||||
|
size="lg"
|
||||||
|
avatarObjectId={user?.avatar_object_id}
|
||||||
|
initials={initials}
|
||||||
|
fallbackClassName="bg-primary/10 text-primary font-medium"
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Camera className="size-4 text-white" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">{user?.email_prefix}</p>
|
<p className="truncate text-sm font-medium">{user?.email_prefix}</p>
|
||||||
<Muted className="text-xs">{user?.email}</Muted>
|
<Muted className="text-xs">{user?.email}</Muted>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AvatarEditDialog open={avatarOpen} onOpenChange={setAvatarOpen} />
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<SettingsGroup title="Notifications">
|
<SettingsGroup title="Notifications">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { AudioLevelBars } from '@/components/audio/audio-level-bars';
|
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
||||||
import { useAudioSource } from '@/components/audio/use-audio-source';
|
import { useAudioSource } from '@/components/audio/use-audio-source';
|
||||||
import { useMediaDevices } from '@/hooks/use-media-devices';
|
import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||||
import {
|
import {
|
||||||
@@ -105,20 +105,17 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
|
|||||||
function InlineLevelMeter({ stream }: { stream: MediaStream | null }) {
|
function InlineLevelMeter({ stream }: { stream: MediaStream | null }) {
|
||||||
const audioSource = useAudioSource(stream);
|
const audioSource = useAudioSource(stream);
|
||||||
if (!audioSource) {
|
if (!audioSource) {
|
||||||
return (
|
return <div className="bg-muted h-3 w-20 rounded-sm opacity-50" />;
|
||||||
<div className="flex h-3 items-end gap-1">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<div key={i} className="bg-muted h-1 w-1 rounded-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex h-3 items-end">
|
<CenteredWaveform
|
||||||
<div className="scale-[0.55] origin-right">
|
sourceNode={audioSource.sourceNode}
|
||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
width={80}
|
||||||
</div>
|
height={12}
|
||||||
</div>
|
barWidth={2}
|
||||||
|
gap={3}
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Camera, Loader2, Trash2, Upload } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Muted } from '@/components/ui/typography';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
||||||
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
|
import { useFileInput } from '@/hooks/use-file-input';
|
||||||
|
import { toAvatarBlob } from '@/lib/avatar-image';
|
||||||
|
import { logError, toUserMessage } from '@/lib/errors';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Guard the *source* file before decoding so we never load a huge image into
|
||||||
|
// memory just to throw most of it away — the uploaded blob is always our small
|
||||||
|
// re-encoded square regardless of input size.
|
||||||
|
const MAX_SOURCE_BYTES = 30 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface AvatarEditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: AvatarEditDialogProps) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||||
|
const currentUrl = useAvatarUrl(user?.avatar_object_id);
|
||||||
|
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?';
|
||||||
|
const hasAvatar = !!user?.avatar_object_id;
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<'upload' | 'camera'>('upload');
|
||||||
|
const [prepared, setPrepared] = useState<{ blob: Blob; url: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Keep the latest prepared blob in a ref so the unmount cleanup can revoke
|
||||||
|
// its object URL without re-running on every change.
|
||||||
|
const preparedRef = useRef(prepared);
|
||||||
|
useEffect(() => {
|
||||||
|
preparedRef.current = prepared;
|
||||||
|
}, [prepared]);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (preparedRef.current) URL.revokeObjectURL(preparedRef.current.url);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPreparedFromBlob = useCallback((blob: Blob) => {
|
||||||
|
setPrepared((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev.url);
|
||||||
|
return { blob, url: URL.createObjectURL(blob) };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close and reset to a clean slate so reopening starts fresh.
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setPrepared((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev.url);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setTab('upload');
|
||||||
|
setBusy(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (next) onOpenChange(true);
|
||||||
|
else close();
|
||||||
|
},
|
||||||
|
[close, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error('Please choose an image file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_SOURCE_BYTES) {
|
||||||
|
toast.error('That image is too large — choose one under 30MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const blob = await toAvatarBlob(bitmap);
|
||||||
|
bitmap.close();
|
||||||
|
setPreparedFromBlob(blob);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Could not process that image.');
|
||||||
|
logError(err, { scope: 'avatar.processFile' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setPreparedFromBlob],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
|
||||||
|
onFilesSelected: handleFiles,
|
||||||
|
enabled: open && tab === 'upload',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!prepared) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiClient.updateAvatar(prepared.blob);
|
||||||
|
await refreshUser();
|
||||||
|
toast.success('Avatar updated');
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
logError(err, { scope: 'avatar.save' });
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiClient.deleteAvatar();
|
||||||
|
await refreshUser();
|
||||||
|
toast.success('Avatar removed');
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
logError(err, { scope: 'avatar.remove' });
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewUrl = prepared?.url ?? currentUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Profile picture</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload an image or take one with your camera.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-4 py-1">
|
||||||
|
<div className="bg-muted flex size-24 items-center justify-center overflow-hidden rounded-full border">
|
||||||
|
{previewUrl ? (
|
||||||
|
<img src={previewUrl} alt="" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-2xl font-medium">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(v as 'upload' | 'camera')}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="upload">
|
||||||
|
<Upload />
|
||||||
|
Upload
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="camera">
|
||||||
|
<Camera />
|
||||||
|
Take photo
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="upload" className="pt-3">
|
||||||
|
<div
|
||||||
|
{...dropZoneProps}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-5 text-center transition-colors',
|
||||||
|
isDragging && 'border-primary bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Muted className="text-xs">Drag an image here, or</Muted>
|
||||||
|
<Button variant="outline" size="sm" onClick={openFilePicker}>
|
||||||
|
Choose image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="camera" className="pt-3">
|
||||||
|
<CameraCapture
|
||||||
|
active={open && tab === 'camera'}
|
||||||
|
onCapture={setPreparedFromBlob}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{hasAvatar && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive sm:mr-auto"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 size-3.5" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={close} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
|
||||||
|
{busy && <Loader2 className="mr-1 size-3.5 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CameraCapture({
|
||||||
|
active,
|
||||||
|
onCapture,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onCapture: (blob: Blob) => void;
|
||||||
|
}) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [capturing, setCapturing] = useState(false);
|
||||||
|
// Honor the camera the user picked in Audio & Video settings. `ideal` rather
|
||||||
|
// than `exact` so a since-unplugged device falls back to the default instead
|
||||||
|
// of throwing OverconstrainedError.
|
||||||
|
const savedCameraId = useMediaDevicesStore((s) => s.camera?.deviceId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let acquired: MediaStream | null = null;
|
||||||
|
|
||||||
|
const video: MediaTrackConstraints = { aspectRatio: { ideal: 1 } };
|
||||||
|
if (savedCameraId) video.deviceId = { ideal: savedCameraId };
|
||||||
|
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ video, audio: false })
|
||||||
|
.then((s) => {
|
||||||
|
if (cancelled) {
|
||||||
|
s.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acquired = s;
|
||||||
|
setStream(s);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : 'Unable to access camera',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
acquired?.getTracks().forEach((t) => t.stop());
|
||||||
|
setStream(null);
|
||||||
|
};
|
||||||
|
}, [active, savedCameraId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) videoRef.current.srcObject = stream;
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
|
const handleCapture = async () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
// readyState < HAVE_CURRENT_DATA (or zero dimensions) means no frame has
|
||||||
|
// decoded yet — capturing now would grab a blank image.
|
||||||
|
if (!video || video.readyState < 2 || !video.videoWidth) {
|
||||||
|
toast.error('Camera is still starting — try again in a moment.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapturing(true);
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(video);
|
||||||
|
// Mirror to match the (mirrored) live preview the user is looking at.
|
||||||
|
const blob = await toAvatarBlob(bitmap, { mirror: true });
|
||||||
|
bitmap.close();
|
||||||
|
onCapture(blob);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Could not capture photo.');
|
||||||
|
logError(err, { scope: 'avatar.capture' });
|
||||||
|
} finally {
|
||||||
|
setCapturing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1 rounded-lg border border-dashed px-6 py-5 text-center">
|
||||||
|
<Muted className="text-xs">Couldn't access your camera.</Muted>
|
||||||
|
<Muted className="text-[11px]">{error}</Muted>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="bg-muted size-36 overflow-hidden rounded-full border">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="size-full -scale-x-100 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleCapture} disabled={!stream || capturing}>
|
||||||
|
<Camera className="mr-1 size-3.5" />
|
||||||
|
Capture
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useQuery, skipToken } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an avatar object id to a signed download URL. Mirrors
|
||||||
|
* {@link import('./use-download-url').useDownloadUrl} — React Query handles
|
||||||
|
* caching and de-duping, so many avatars sharing an id make a single request.
|
||||||
|
*/
|
||||||
|
export function useAvatarUrl(
|
||||||
|
objectId: string | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['avatar-url', objectId],
|
||||||
|
queryFn: objectId
|
||||||
|
? () => apiClient.getAvatarDownloadUrl(objectId)
|
||||||
|
: skipToken,
|
||||||
|
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export interface HumanPresence {
|
|||||||
humanId: string;
|
humanId: string;
|
||||||
email: string;
|
email: string;
|
||||||
emailPrefix: string;
|
emailPrefix: string;
|
||||||
|
avatarObjectId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +43,7 @@ export function usePresencePositions(
|
|||||||
humanId: human.id,
|
humanId: human.id,
|
||||||
email: human.email,
|
email: human.email,
|
||||||
emailPrefix: human.email_prefix,
|
emailPrefix: human.email_prefix,
|
||||||
|
avatarObjectId: human.avatar_object_id ?? null,
|
||||||
};
|
};
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.push(presence);
|
existing.push(presence);
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
export type RecordingMode = 'video' | 'audio';
|
|
||||||
|
|
||||||
const KEY = 'llink:recording-mode';
|
|
||||||
|
|
||||||
export function useRecordingMode(): [
|
|
||||||
RecordingMode,
|
|
||||||
(mode: RecordingMode) => void,
|
|
||||||
] {
|
|
||||||
const [mode, setModeState] = useState<RecordingMode>(() => {
|
|
||||||
const stored = localStorage.getItem(KEY);
|
|
||||||
return stored === 'audio' ? 'audio' : 'video';
|
|
||||||
});
|
|
||||||
|
|
||||||
const setMode = useCallback((m: RecordingMode) => {
|
|
||||||
localStorage.setItem(KEY, m);
|
|
||||||
setModeState(m);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [mode, setMode];
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, type RefObject } from 'react';
|
import { useEffect, type RefObject } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
|
import type { MediaParticleHandle } from '@/features/particles/media-particle-view';
|
||||||
import { isTypingTarget } from '@/lib/keyboard';
|
import { isTypingTarget } from '@/lib/keyboard';
|
||||||
|
|
||||||
@@ -11,6 +10,7 @@ interface UseStreamNavigationKeysOptions {
|
|||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
childrenLength: number;
|
childrenLength: number;
|
||||||
mediaRef: RefObject<MediaParticleHandle | null>;
|
mediaRef: RefObject<MediaParticleHandle | null>;
|
||||||
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,9 +23,8 @@ export function useStreamNavigationKeys({
|
|||||||
currentIndex,
|
currentIndex,
|
||||||
childrenLength,
|
childrenLength,
|
||||||
mediaRef,
|
mediaRef,
|
||||||
|
onExit,
|
||||||
}: UseStreamNavigationKeysOptions) {
|
}: UseStreamNavigationKeysOptions) {
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (isTypingTarget(e)) return;
|
if (isTypingTarget(e)) return;
|
||||||
@@ -53,12 +52,12 @@ export function useStreamNavigationKeys({
|
|||||||
break;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(-1);
|
onExit();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [next, prev, currentIndex, childrenLength, mediaRef, navigate]);
|
}, [next, prev, currentIndex, childrenLength, mediaRef, onExit]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Center-crop an image source to a square and downscale it to a JPEG suitable
|
||||||
|
* for an avatar. Avatars never render larger than ~80px, so 512px is generous
|
||||||
|
* headroom while keeping the upload to a few tens of KB regardless of input.
|
||||||
|
*
|
||||||
|
* Pass `mirror` when capturing from a (mirrored) webcam preview so the saved
|
||||||
|
* image matches what the user saw.
|
||||||
|
*/
|
||||||
|
export async function toAvatarBlob(
|
||||||
|
source: ImageBitmap,
|
||||||
|
{ size = 512, mirror = false }: { size?: number; mirror?: boolean } = {},
|
||||||
|
): Promise<Blob> {
|
||||||
|
if (!Number.isInteger(size) || size <= 0) {
|
||||||
|
throw new Error('Avatar size must be a positive integer');
|
||||||
|
}
|
||||||
|
const side = Math.min(source.width, source.height);
|
||||||
|
if (side === 0) {
|
||||||
|
// A not-yet-decoded <video> or a corrupt image yields a zero-size source;
|
||||||
|
// cropping it would silently produce a blank avatar, so fail instead.
|
||||||
|
throw new Error('Image source has zero dimensions');
|
||||||
|
}
|
||||||
|
const sx = (source.width - side) / 2;
|
||||||
|
const sy = (source.height - side) / 2;
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(size, size);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Failed to acquire 2D canvas context');
|
||||||
|
|
||||||
|
if (mirror) {
|
||||||
|
ctx.translate(size, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
}
|
||||||
|
ctx.drawImage(source, sx, sy, side, side, 0, 0, size, size);
|
||||||
|
|
||||||
|
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.82 });
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export interface HumanDisplay {
|
|||||||
email: string;
|
email: string;
|
||||||
/** Initials for avatar fallback. */
|
/** Initials for avatar fallback. */
|
||||||
initials: string;
|
initials: string;
|
||||||
|
/** Avatar object id, when the human has a profile picture set. */
|
||||||
|
avatarObjectId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +34,7 @@ export function resolveHumanDisplay(
|
|||||||
displayName: REMOVED_MEMBER_LABEL,
|
displayName: REMOVED_MEMBER_LABEL,
|
||||||
email: REMOVED_MEMBER_LABEL,
|
email: REMOVED_MEMBER_LABEL,
|
||||||
initials: REMOVED_MEMBER_INITIALS,
|
initials: REMOVED_MEMBER_INITIALS,
|
||||||
|
avatarObjectId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -39,5 +42,6 @@ export function resolveHumanDisplay(
|
|||||||
displayName: human.email_prefix,
|
displayName: human.email_prefix,
|
||||||
email: human.email,
|
email: human.email,
|
||||||
initials: getInitials(human.email),
|
initials: getInitials(human.email),
|
||||||
|
avatarObjectId: human.avatar_object_id ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface AuthState {
|
|||||||
isSigningOut: boolean;
|
isSigningOut: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
restoreSession: () => Promise<void>;
|
restoreSession: () => Promise<void>;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
requestCode: (email: string) => Promise<void>;
|
requestCode: (email: string) => Promise<void>;
|
||||||
signIn: (email: string, code: string) => Promise<void>;
|
signIn: (email: string, code: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
@@ -64,6 +65,11 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshUser: async () => {
|
||||||
|
const user = await apiClient.me();
|
||||||
|
set({ user });
|
||||||
|
},
|
||||||
|
|
||||||
requestCode: async (email: string) => {
|
requestCode: async (email: string) => {
|
||||||
set({ isRequestingCode: true, error: null });
|
set({ isRequestingCode: true, error: null });
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user