Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66d3897f66 | ||
|
|
17bb0e1846 | ||
|
|
17c85493b7 | ||
|
|
75f9c57eca | ||
|
|
8a007be036 | ||
|
|
f77148df2c | ||
|
|
139dd226fa | ||
|
|
795bb7de46 | ||
|
|
11b890c2e7 | ||
|
|
59983ed1b3 | ||
|
|
cb22f15912 | ||
|
|
6450027b88 | ||
|
|
f087ec9f88 | ||
|
|
cd84ceecb2 | ||
|
|
582c0dc3d4 | ||
|
|
1a693848f2 | ||
|
|
11a16cd023 | ||
|
|
0a05bfe2cd | ||
|
|
f1528de948 | ||
|
|
0a4cd4bff1 | ||
|
|
eed7038109 | ||
|
|
7fa7ee2c7d | ||
|
|
24707a0bfc | ||
|
|
a6c9fd8a00 | ||
|
|
bc55528afd | ||
|
|
a29a8b18ad | ||
|
|
e33abc948c | ||
|
|
ab49e22357 | ||
|
|
75ae2092ad |
No files matched your search
@@ -71,7 +71,9 @@ jobs:
|
||||
done
|
||||
- name: Go — миграции и запросы store
|
||||
working-directory: services/api
|
||||
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
||||
# These suites share one database; dbtest also rolls all migrations down
|
||||
# and cleans orphan catalog rows. Do not run the packages concurrently.
|
||||
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/
|
||||
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
|
||||
working-directory: services/api
|
||||
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
|
||||
|
||||
@@ -47,6 +47,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Обновление сервера, агент и конфигурация
|
||||
run: python3 -m unittest discover -s deploy -p 'test_*.py' -v
|
||||
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
||||
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
||||
|
||||
@@ -20,6 +20,8 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
|
||||
# env / secrets
|
||||
deploy/config.local
|
||||
deploy/.management.lock
|
||||
.env
|
||||
*.pem
|
||||
*.keystore
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
|
||||
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
|
||||
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
|
||||
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе.
|
||||
- Общие проверки: `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
|
||||
- Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в CI/CD; не запускай все проверки проекта перед каждой сдачей без необходимости. Расширяй локальную проверку при изменении общих контрактов, межмодульных зависимостей или при обнаружении ошибок. Команды запускаются из корня, если не сказано иначе.
|
||||
- Справочник полных проверок (выбирай локально только относящиеся к изменению): `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
|
||||
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
|
||||
@@ -22,6 +22,377 @@ tags:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/v1/me/social/recommendations:
|
||||
put:
|
||||
operationId: setFriendsRecommendations
|
||||
tags: [me]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled: {type: boolean}
|
||||
responses:
|
||||
'204': {description: Saved}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/listening:
|
||||
get:
|
||||
operationId: getListeningState
|
||||
tags: [me]
|
||||
responses:
|
||||
'200':
|
||||
description: Listening preferences, current room and pending requests
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ListeningState'}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/listening/preferences:
|
||||
put:
|
||||
operationId: setListeningPreferences
|
||||
tags: [me]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ListeningPreferences'}
|
||||
responses:
|
||||
'204': {description: Saved}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/listening/actions:
|
||||
post:
|
||||
operationId: listeningAction
|
||||
tags: [me]
|
||||
description: Join requests target the host; invites target a guest and use the sender's own stream. Accept/decline/cancel target a request UUID. Leave/end target the room UUID. Automatic acceptance always respects the receiving user's admission policy. No control transfer.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ListeningAction'}
|
||||
responses:
|
||||
'204': {description: Applied}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/listening/state:
|
||||
put:
|
||||
operationId: publishListeningState
|
||||
tags: [me]
|
||||
description: Only the room owner's source device may publish. Monotonic sequence prevents stale updates; room expires after 30 seconds without host heartbeat.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ListeningRoomInput'}
|
||||
responses:
|
||||
'204': {description: Applied}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/activity:
|
||||
put:
|
||||
operationId: publishListeningActivity
|
||||
tags: [me]
|
||||
description: Ephemeral playback lease, expires after 90 seconds. Ignored when visibility is nobody. Sequence is monotonic per device; paused updates clear only that device. No offline replay.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ListeningActivityInput'}
|
||||
responses:
|
||||
'204': {description: Applied}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/activity/visibility:
|
||||
put:
|
||||
operationId: setActivityVisibility
|
||||
tags: [me]
|
||||
description: Applies to all devices. Nobody clears all current activity immediately.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [visibility]
|
||||
properties:
|
||||
visibility: {$ref: '#/components/schemas/ActivityVisibility'}
|
||||
responses:
|
||||
'204': {description: Saved}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/social/activity/{userId}/playlist:
|
||||
get:
|
||||
operationId: getActivityPlaylist
|
||||
tags: [me]
|
||||
description: Read-only current playlist subject to live visibility, friendship, blocking and expiry. Does not create or reveal a permanent share token.
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema: {type: string, format: uuid}
|
||||
responses:
|
||||
'200':
|
||||
description: Read-only playlist
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/Playlist'}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social:
|
||||
get:
|
||||
operationId: getSocial
|
||||
tags: [me]
|
||||
description: Friends, incoming/outgoing requests, own blocks and up to 100 notifications, unread first. No private account fields.
|
||||
responses:
|
||||
'200':
|
||||
description: Social inbox
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SocialInbox'}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/people:
|
||||
get:
|
||||
operationId: searchPeople
|
||||
tags: [me]
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
required: true
|
||||
schema: {type: string, minLength: 2, maxLength: 80}
|
||||
responses:
|
||||
'200':
|
||||
description: Up to 30 people, excluding blocked accounts and either direction of personal blocking
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/SocialPerson'}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/actions:
|
||||
post:
|
||||
operationId: socialAction
|
||||
tags: [me]
|
||||
description: Request is idempotent; accept is recipient-only. Remove cancels, declines or unfriends. Block removes friendship. Unblock never restores it. Read chat acknowledges only messages through up_to_seq. Read notification uses notification ID as target_id.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SocialAction'}
|
||||
responses:
|
||||
'204': {description: Applied}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/chats/{peerId}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: peerId
|
||||
required: true
|
||||
schema: {type: string, format: uuid}
|
||||
get:
|
||||
operationId: getDirectMessages
|
||||
tags: [me]
|
||||
description: Last 50 messages in ascending sequence order. Before loads older history. Requires current friendship, never marks messages read.
|
||||
parameters:
|
||||
- in: query
|
||||
name: before
|
||||
schema: {type: integer, format: int64, minimum: 1}
|
||||
- in: query
|
||||
name: q
|
||||
schema: {type: string, maxLength: 100}
|
||||
- in: query
|
||||
name: music
|
||||
description: Only non-deleted track and playlist attachments, with the same history pagination.
|
||||
schema: {type: boolean, default: false}
|
||||
responses:
|
||||
'200':
|
||||
description: Message page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/DirectMessage'}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
post:
|
||||
operationId: sendDirectMessage
|
||||
tags: [me]
|
||||
description: Friends only. Client-generated UUID makes retries idempotent; changed payload for the same ID returns 409. Playlist attachment is its active share UUID; track attachment is a catalog UUID. No arbitrary URLs.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SendDirectMessage'}
|
||||
responses:
|
||||
'200':
|
||||
description: Stored message
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/DirectMessage'}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/chats/{peerId}/meta:
|
||||
parameters:
|
||||
- in: path
|
||||
name: peerId
|
||||
required: true
|
||||
schema: {type: string, format: uuid}
|
||||
get:
|
||||
operationId: getChatMeta
|
||||
tags: [me]
|
||||
responses:
|
||||
'200':
|
||||
description: Ephemeral typing indicator, friends only
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [typing]
|
||||
properties:
|
||||
typing: {type: boolean}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
put:
|
||||
operationId: setChatTyping
|
||||
tags: [me]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [typing]
|
||||
properties:
|
||||
typing: {type: boolean}
|
||||
responses:
|
||||
'204': {description: Applied, lease expires after 6 seconds}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/me/social/chats/{peerId}/messages/{messageId}:
|
||||
patch:
|
||||
operationId: updateChatMessage
|
||||
tags: [me]
|
||||
description: Friends only. Edit/delete are sender-only. Delete removes content for both participants; react toggles one allowed emoji. Reply targets must belong to the same conversation.
|
||||
parameters:
|
||||
- in: path
|
||||
name: peerId
|
||||
required: true
|
||||
schema: {type: string, format: uuid}
|
||||
- in: path
|
||||
name: messageId
|
||||
required: true
|
||||
schema: {type: string, format: uuid}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action: {type: string, enum: [edit, delete, react]}
|
||||
text: {type: string, maxLength: 4000}
|
||||
emoji: {type: string, enum: ['❤️', '👍', '🔥', '😂', '🎵', '👏']}
|
||||
responses:
|
||||
'204': {description: Applied}
|
||||
default: {$ref: '#/components/responses/Problem'}
|
||||
/api/v1/admin/server/updates:
|
||||
get:
|
||||
operationId: getServerUpdates
|
||||
tags: [admin]
|
||||
parameters:
|
||||
- in: query
|
||||
name: source_id
|
||||
schema: {type: string}
|
||||
- in: query
|
||||
name: channel
|
||||
schema: {type: string, enum: [latest, version, main], default: latest}
|
||||
- in: query
|
||||
name: version
|
||||
schema: {type: string}
|
||||
- in: query
|
||||
name: fresh
|
||||
schema: {type: boolean, default: false}
|
||||
responses:
|
||||
"200":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerUpdates"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
post:
|
||||
operationId: startServerUpdate
|
||||
tags: [admin]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerUpdateRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerJob"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/server/update-sources:
|
||||
put:
|
||||
operationId: saveServerUpdateSources
|
||||
tags: [admin]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerSources"
|
||||
responses:
|
||||
"200":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerSources"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/server/config:
|
||||
get:
|
||||
operationId: getServerConfig
|
||||
tags: [admin]
|
||||
responses:
|
||||
"200":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerConfig"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
patch:
|
||||
operationId: saveServerConfig
|
||||
tags: [admin]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerConfigPatch"
|
||||
responses:
|
||||
"200":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerConfig"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/server/config/apply:
|
||||
post:
|
||||
operationId: applyServerConfig
|
||||
tags: [admin]
|
||||
responses:
|
||||
"202":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerJob"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/genius:
|
||||
get:
|
||||
operationId: getGeniusStats
|
||||
@@ -353,6 +724,61 @@ paths:
|
||||
$ref: "#/components/schemas/CreatedUser"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/sources/{id}/cover-stats:
|
||||
get:
|
||||
operationId: getSourceCoverStats
|
||||
tags: [admin]
|
||||
summary: Покрытие аудиофайлов локальными обложками (admin)
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SourceId"
|
||||
responses:
|
||||
"200":
|
||||
description: Доступные аудиофайлы без исключённых папок; внешние обложки не учитываются
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SourceCoverStats"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/users/export:
|
||||
get:
|
||||
operationId: exportUsers
|
||||
tags: [admin]
|
||||
summary: Экспорт учётных записей с ролями и хэшами паролей (admin)
|
||||
description: Только учётные записи и права доступа, без рекомендаций, истории, лайков и сессий. Ответ не кэшируется.
|
||||
responses:
|
||||
"200":
|
||||
description: Файл переноса пользователей
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserArchive"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/users/import:
|
||||
post:
|
||||
operationId: importUsers
|
||||
tags: [admin]
|
||||
summary: Импорт учётных записей с сохранением паролей (admin)
|
||||
description: |
|
||||
Атомарное добавление пользователей из экспорта, до 10000 записей и 16 MiB.
|
||||
Существующие логины пропускаются без изменений. Новые записи получают новые ID.
|
||||
Рекомендации, история, лайки и сессии не импортируются.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserArchive"
|
||||
responses:
|
||||
"200":
|
||||
description: Результат импорта
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserImportResult"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/users/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserId"
|
||||
@@ -1832,6 +2258,278 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
schemas:
|
||||
ListeningPreferences:
|
||||
type: object
|
||||
required: [requests, auto_accept, autoplay]
|
||||
properties:
|
||||
requests: {$ref: '#/components/schemas/ActivityVisibility'}
|
||||
auto_accept: {$ref: '#/components/schemas/ActivityVisibility'}
|
||||
autoplay: {type: boolean, default: true}
|
||||
ListeningAction:
|
||||
type: object
|
||||
required: [id, action, target_id]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
action: {type: string, enum: [join, invite, accept, decline, cancel, leave, end]}
|
||||
target_id: {type: string, format: uuid}
|
||||
device_id: {type: string, format: uuid}
|
||||
track_id: {type: string, format: uuid}
|
||||
position_ms: {type: integer, minimum: 0}
|
||||
playing: {type: boolean}
|
||||
ListeningRoomInput:
|
||||
type: object
|
||||
required: [room_id, device_id, sequence, playing, position_ms]
|
||||
properties:
|
||||
room_id: {type: string, format: uuid}
|
||||
device_id: {type: string, format: uuid}
|
||||
sequence: {type: integer, format: int64, minimum: 1}
|
||||
track_id: {type: string, format: uuid}
|
||||
playing: {type: boolean}
|
||||
position_ms: {type: integer, minimum: 0}
|
||||
ListeningRoom:
|
||||
type: object
|
||||
required: [id, owner_id, owner_name, device_id, track_id, position_ms, playing, updated_at, participants]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
owner_id: {type: string, format: uuid}
|
||||
owner_name: {type: string}
|
||||
device_id: {type: string, format: uuid}
|
||||
track_id: {type: string, format: uuid, nullable: true}
|
||||
position_ms: {type: integer}
|
||||
playing: {type: boolean}
|
||||
updated_at: {type: string, format: date-time}
|
||||
participants:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [id, name]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
name: {type: string}
|
||||
ListeningRequest:
|
||||
type: object
|
||||
required: [id, sender_id, sender_name, recipient_id, owner_id, kind, expires_at]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
sender_id: {type: string, format: uuid}
|
||||
sender_name: {type: string}
|
||||
recipient_id: {type: string, format: uuid}
|
||||
owner_id: {type: string, format: uuid}
|
||||
kind: {type: string, enum: [join, invite]}
|
||||
expires_at: {type: string, format: date-time}
|
||||
ListeningState:
|
||||
type: object
|
||||
required: [preferences, room, requests, server_time]
|
||||
properties:
|
||||
preferences: {$ref: '#/components/schemas/ListeningPreferences'}
|
||||
room:
|
||||
nullable: true
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ListeningRoom'}
|
||||
requests:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/ListeningRequest'}
|
||||
server_time: {type: string, format: date-time}
|
||||
ActivityVisibility:
|
||||
type: string
|
||||
enum: [nobody, friends, everyone]
|
||||
default: nobody
|
||||
ListeningActivityInput:
|
||||
type: object
|
||||
required: [device_id, sequence, playing, context]
|
||||
properties:
|
||||
device_id: {type: string, format: uuid}
|
||||
sequence: {type: integer, format: int64, minimum: 1}
|
||||
playing: {type: boolean}
|
||||
position_ms: {type: integer, minimum: 0, default: 0}
|
||||
track_id: {type: string, format: uuid}
|
||||
context: {type: string, enum: [other, wave, playlist]}
|
||||
playlist_id: {type: string, format: uuid}
|
||||
ListeningActivity:
|
||||
type: object
|
||||
required: [track_id, track_title, artist, context]
|
||||
properties:
|
||||
track_id: {type: string, format: uuid}
|
||||
track_title: {type: string}
|
||||
artist: {type: string}
|
||||
context: {type: string, enum: [other, wave, playlist]}
|
||||
playlist_id: {type: string, format: uuid}
|
||||
playlist_name: {type: string}
|
||||
SocialPerson:
|
||||
type: object
|
||||
required: [id, username, display_name, relationship, unread, online]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
username: {type: string}
|
||||
display_name: {type: string}
|
||||
relationship: {type: string, enum: [none, incoming, outgoing, friend, blocked]}
|
||||
online:
|
||||
type: boolean
|
||||
description: A current friend has an active client heartbeat within 45 seconds. Independent of music visibility; false for non-friends and blocked people.
|
||||
unread: {type: integer}
|
||||
activity: {$ref: '#/components/schemas/ListeningActivity'}
|
||||
last_message: {$ref: '#/components/schemas/SocialMessagePreview'}
|
||||
SocialMessagePreview:
|
||||
type: object
|
||||
description: Latest message in a current friendship, without marking it read. Deleted content is never returned.
|
||||
required: [id, sender_id, text, attachment_kind, created_at, deleted]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
sender_id: {type: string, format: uuid}
|
||||
text: {type: string}
|
||||
attachment_kind: {type: string, enum: [none, track, playlist]}
|
||||
created_at: {type: string, format: date-time}
|
||||
deleted: {type: boolean}
|
||||
SocialNotification:
|
||||
type: object
|
||||
required: [id, actor_id, actor_name, kind, created_at, read]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
actor_id: {type: string, format: uuid}
|
||||
actor_name: {type: string}
|
||||
kind: {type: string, enum: [friend_request, friend_accepted, message, music, listening_request, listening_invite, listening_accepted]}
|
||||
created_at: {type: string, format: date-time}
|
||||
read: {type: boolean}
|
||||
SocialInbox:
|
||||
type: object
|
||||
required: [people, notifications, unread, activity_visibility, friends_recommendations]
|
||||
properties:
|
||||
friends_recommendations: {type: boolean, default: true}
|
||||
activity_visibility: {$ref: '#/components/schemas/ActivityVisibility'}
|
||||
people:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/SocialPerson'}
|
||||
notifications:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/SocialNotification'}
|
||||
unread: {type: integer}
|
||||
SocialAction:
|
||||
type: object
|
||||
required: [action, target_id]
|
||||
properties:
|
||||
action: {type: string, enum: [request, accept, remove, block, unblock, read_chat, read_notification]}
|
||||
target_id: {type: string, format: uuid}
|
||||
up_to_seq: {type: integer, format: int64, minimum: 1}
|
||||
SendDirectMessage:
|
||||
type: object
|
||||
required: [id, text, attachment_kind]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
text: {type: string, maxLength: 4000}
|
||||
attachment_kind: {type: string, enum: [none, track, playlist]}
|
||||
attachment_id: {type: string, format: uuid}
|
||||
reply_to: {type: string, format: uuid}
|
||||
DirectMessage:
|
||||
type: object
|
||||
required: [id, seq, sender_id, text, attachment_kind, created_at, read]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
seq: {type: integer, format: int64}
|
||||
sender_id: {type: string, format: uuid}
|
||||
text: {type: string}
|
||||
attachment_kind: {type: string, enum: [none, track, playlist]}
|
||||
attachment_id: {type: string, format: uuid}
|
||||
created_at: {type: string, format: date-time}
|
||||
read: {type: boolean}
|
||||
reply_to: {type: string, format: uuid}
|
||||
reply_preview: {type: string}
|
||||
edited_at: {type: string, format: date-time}
|
||||
deleted: {type: boolean}
|
||||
reactions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MessageReaction'
|
||||
MessageReaction:
|
||||
type: object
|
||||
required: [emoji, count, mine]
|
||||
properties:
|
||||
emoji: {type: string}
|
||||
count: {type: integer}
|
||||
mine: {type: boolean}
|
||||
ServerSource:
|
||||
type: object
|
||||
required: [id, name, url]
|
||||
properties:
|
||||
id: {type: string, maxLength: 64}
|
||||
name: {type: string, maxLength: 100}
|
||||
url: {type: string, maxLength: 2048}
|
||||
ServerSources:
|
||||
type: object
|
||||
required: [sources]
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
maxItems: 20
|
||||
items: {$ref: "#/components/schemas/ServerSource"}
|
||||
ServerJob:
|
||||
type: object
|
||||
required: [id, kind, state, started_at, finished_at, target, log]
|
||||
properties:
|
||||
id: {type: string}
|
||||
kind: {type: string, enum: [update, config]}
|
||||
state: {type: string, enum: [running, succeeded, failed]}
|
||||
started_at: {type: string}
|
||||
finished_at: {type: string}
|
||||
target: {type: string}
|
||||
log: {type: string}
|
||||
ServerUpdates:
|
||||
type: object
|
||||
required: [sources, versions, current_version, job, source_error, current_commit, target_version, target_commit, update_state]
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
items: {$ref: "#/components/schemas/ServerSource"}
|
||||
versions:
|
||||
type: array
|
||||
items: {type: string}
|
||||
current_version: {type: string}
|
||||
current_commit: {type: string}
|
||||
target_version: {type: string}
|
||||
target_commit: {type: string}
|
||||
update_state: {type: string, enum: [available, current, ahead, unknown, unavailable, select_version]}
|
||||
source_error: {type: string}
|
||||
job:
|
||||
allOf: [{$ref: "#/components/schemas/ServerJob"}]
|
||||
nullable: true
|
||||
ServerUpdateRequest:
|
||||
type: object
|
||||
required: [source_id, channel]
|
||||
properties:
|
||||
source_id: {type: string}
|
||||
channel: {type: string, enum: [latest, version, main]}
|
||||
version: {type: string}
|
||||
expected_commit: {type: string, pattern: '^[a-f0-9]{40,64}$'}
|
||||
ServerConfigEntry:
|
||||
type: object
|
||||
required: [key, group, type, secret, readonly, effective, env, local, default_value, source]
|
||||
properties:
|
||||
key: {type: string}
|
||||
group: {type: string}
|
||||
type: {type: string}
|
||||
secret: {type: boolean}
|
||||
readonly: {type: boolean}
|
||||
effective: {type: string}
|
||||
env: {type: string, nullable: true}
|
||||
local: {type: string, nullable: true}
|
||||
default_value: {type: string}
|
||||
source: {type: string, enum: [env, local, default]}
|
||||
ServerConfig:
|
||||
type: object
|
||||
required: [entries, revision, restart_required]
|
||||
properties:
|
||||
revision: {type: string}
|
||||
restart_required: {type: boolean}
|
||||
entries:
|
||||
type: array
|
||||
items: {$ref: "#/components/schemas/ServerConfigEntry"}
|
||||
ServerConfigPatch:
|
||||
type: object
|
||||
required: [revision, values]
|
||||
properties:
|
||||
revision: {type: string}
|
||||
values:
|
||||
type: object
|
||||
additionalProperties: {type: string, nullable: true}
|
||||
PlaylistShare:
|
||||
type: object
|
||||
required: [share_id]
|
||||
@@ -2495,6 +3193,9 @@ components:
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
manages_updates:
|
||||
type: boolean
|
||||
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
|
||||
@@ -2536,6 +3237,70 @@ components:
|
||||
type: string
|
||||
new_password:
|
||||
type: string
|
||||
SourceCoverStats:
|
||||
type: object
|
||||
required: [files_total, files_with_cover, albums_pending]
|
||||
properties:
|
||||
files_total:
|
||||
type: integer
|
||||
format: int64
|
||||
files_with_cover:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Файлы, альбом которых имеет готовую локальную обложку (встроенную или из папки)
|
||||
albums_pending:
|
||||
type: integer
|
||||
format: int64
|
||||
UserArchive:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [version, users]
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
enum: [1]
|
||||
users:
|
||||
type: array
|
||||
maxItems: 10000
|
||||
items:
|
||||
$ref: "#/components/schemas/UserArchiveEntry"
|
||||
UserArchiveEntry:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [username, display_name, role, password_hash, is_blocked, must_change_password, must_change_credentials, reviews_uploads, manages_updates]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 64
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
password_hash:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: Argon2id PHC. Секретные данные; файл нужно хранить в защищённом месте.
|
||||
is_blocked:
|
||||
type: boolean
|
||||
must_change_password:
|
||||
type: boolean
|
||||
must_change_credentials:
|
||||
type: boolean
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
manages_updates:
|
||||
type: boolean
|
||||
UserImportResult:
|
||||
type: object
|
||||
required: [created, skipped]
|
||||
properties:
|
||||
created:
|
||||
type: integer
|
||||
skipped:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
AdminUser:
|
||||
type: object
|
||||
required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
|
||||
@@ -2550,6 +3315,9 @@ components:
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
manages_updates:
|
||||
type: boolean
|
||||
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
|
||||
@@ -2590,6 +3358,9 @@ components:
|
||||
maxLength: 64
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
manages_updates:
|
||||
type: boolean
|
||||
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Сразу выдать аудит загрузок. Может задать только админ
|
||||
@@ -2616,6 +3387,9 @@ components:
|
||||
$ref: "#/components/schemas/Role"
|
||||
is_blocked:
|
||||
type: boolean
|
||||
manages_updates:
|
||||
type: boolean
|
||||
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
|
||||
@@ -2687,6 +3461,8 @@ components:
|
||||
type: object
|
||||
required: [id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, scan_interval_minutes, accepts_uploads, write_template, status, status_message, last_scan_at, created_at, scan]
|
||||
properties:
|
||||
excluded_dirs:
|
||||
$ref: "#/components/schemas/ExcludedDirs"
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -2747,6 +3523,8 @@ components:
|
||||
type: object
|
||||
required: [name, type]
|
||||
properties:
|
||||
excluded_dirs:
|
||||
$ref: "#/components/schemas/ExcludedDirs"
|
||||
name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
@@ -2774,6 +3552,8 @@ components:
|
||||
SourcePatch:
|
||||
type: object
|
||||
properties:
|
||||
excluded_dirs:
|
||||
$ref: "#/components/schemas/ExcludedDirs"
|
||||
name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
@@ -2802,6 +3582,14 @@ components:
|
||||
description: |
|
||||
Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid`
|
||||
с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`.
|
||||
ExcludedDirs:
|
||||
type: array
|
||||
maxItems: 100
|
||||
description: Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1000
|
||||
SourceTestResult:
|
||||
type: object
|
||||
required: [ok, message, audio_files]
|
||||
|
||||
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||
axum = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod media;
|
||||
mod proxy;
|
||||
mod store;
|
||||
mod update;
|
||||
|
||||
use media::Media;
|
||||
use proxy::Proxy;
|
||||
@@ -110,6 +111,7 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(update::Updater::default())
|
||||
.setup(|app| {
|
||||
let saved = store::load_server(app.handle())?;
|
||||
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||
@@ -150,7 +152,8 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set,
|
||||
update::update_check, update::update_install
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("desktop app failed to start");
|
||||
|
||||
@@ -86,7 +86,7 @@ fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||
}
|
||||
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after, x-pipu-transport-error"));
|
||||
}
|
||||
|
||||
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
@@ -110,7 +110,11 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
} else {
|
||||
match transfer(&proxy, request, tail).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||
Err(_) => {
|
||||
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
|
||||
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
|
||||
response
|
||||
},
|
||||
}
|
||||
};
|
||||
cors(response.headers_mut(), origin.as_deref());
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Server-proxied releases: never accept installer paths or commands from the UI.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{io::Write, path::Path, time::Duration};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct Build {
|
||||
client: String,
|
||||
version: String,
|
||||
filename: String,
|
||||
size: u64,
|
||||
sha256: Option<String>,
|
||||
download_url: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct Release { build: Build, notes: String }
|
||||
#[derive(Default)]
|
||||
pub struct Updater { release: Mutex<Option<(String, Release)>> }
|
||||
|
||||
fn client() -> &'static str {
|
||||
if cfg!(target_os = "windows") { "windows" }
|
||||
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
|
||||
}
|
||||
|
||||
fn version_code(raw: &str) -> Option<u64> {
|
||||
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
|
||||
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
|
||||
let rc: u64 = rc.parse().ok()?;
|
||||
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
|
||||
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
|
||||
}
|
||||
|
||||
fn http() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10)).read_timeout(Duration::from_secs(60))
|
||||
.build().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
|
||||
let mut saved = updater.release.lock().await;
|
||||
*saved = None;
|
||||
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
|
||||
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
|
||||
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
|
||||
.json().await.map_err(|e| e.to_string())?;
|
||||
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
|
||||
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
|
||||
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
|
||||
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
|
||||
let release = Release { build, notes };
|
||||
validate_build(&release.build)?;
|
||||
*saved = Some((server, release.clone()));
|
||||
Ok(Some(release))
|
||||
}
|
||||
|
||||
fn validate_build(build: &Build) -> Result<(), String> {
|
||||
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
|
||||
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
|
||||
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
|
||||
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
|
||||
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
|
||||
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
|
||||
return Err("Invalid installer URL".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
|
||||
let saved = updater.release.lock().await;
|
||||
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
|
||||
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
|
||||
let build = &release.build;
|
||||
validate_build(build)?;
|
||||
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(&build.filename);
|
||||
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
|
||||
let result = async {
|
||||
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
|
||||
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
|
||||
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut bytes = 0u64;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
bytes += chunk.len() as u64;
|
||||
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
|
||||
hash.update(&chunk);
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
|
||||
}
|
||||
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
|
||||
file.sync_all().map_err(|e| e.to_string())?;
|
||||
drop(file);
|
||||
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
|
||||
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
|
||||
launch(&path)?;
|
||||
Ok(())
|
||||
}.await;
|
||||
let _ = std::fs::remove_file(&part);
|
||||
result
|
||||
}
|
||||
|
||||
fn launch(path: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
|
||||
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
|
||||
} else { std::process::Command::new(path) };
|
||||
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
|
||||
#[cfg(target_os = "macos")]
|
||||
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn release_order() {
|
||||
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
|
||||
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
|
||||
assert_eq!(version_code("1.7.1"), Some(1070199));
|
||||
assert_eq!(version_code("1.7.100"), None);
|
||||
assert_eq!(version_code("1.7.1;cmd"), None);
|
||||
}
|
||||
#[test]
|
||||
fn installer_is_restricted_to_verified_server_asset() {
|
||||
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
|
||||
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
|
||||
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
|
||||
assert!(validate_build(&build).is_ok());
|
||||
build.download_url = "https://other.example/installer.exe".into();
|
||||
assert!(validate_build(&build).is_err());
|
||||
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
|
||||
build.filename = "../installer.exe".into();
|
||||
assert!(validate_build(&build).is_err());
|
||||
build.filename = format!("pipu.{extension}");
|
||||
build.sha256 = None;
|
||||
assert!(validate_build(&build).is_err());
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"withGlobalTauri": true,
|
||||
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -8,32 +8,38 @@ import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { useColors } from '../../theme';
|
||||
import { Cover, Icon, type IconName } from '../../ui/kit';
|
||||
import { NativeChatProvider, useSocialUnread } from '../../features/social/Social';
|
||||
import { ListeningBanner } from '../../features/social/Listening';
|
||||
import { UpdateDialog } from '../../ui/UpdateDialog';
|
||||
|
||||
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
|
||||
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
|
||||
|
||||
export default function TabsLayout() {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const unread = useSocialUnread();
|
||||
const labels: Record<string, string> = {
|
||||
index: t('nav.home'),
|
||||
search: t('mobile.search'),
|
||||
collection: t('nav.collection'),
|
||||
profile: t('mobile.profile'),
|
||||
friends: t('social.people'),
|
||||
};
|
||||
const net = useNetInfo();
|
||||
return (
|
||||
<>
|
||||
<NativeChatProvider>
|
||||
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
||||
<Tabs
|
||||
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
||||
tabBar={({ state, navigation }) => (
|
||||
<View>
|
||||
<ListeningBanner />
|
||||
<MiniPlayer />
|
||||
<TabBar
|
||||
routes={state.routes.map((r) => r.name)}
|
||||
active={state.index}
|
||||
labels={labels}
|
||||
badges={{ friends: unread }}
|
||||
onPress={(name) => navigation.navigate(name)}
|
||||
/>
|
||||
</View>
|
||||
@@ -42,10 +48,11 @@ export default function TabsLayout() {
|
||||
<Tabs.Screen name="index" />
|
||||
<Tabs.Screen name="search" />
|
||||
<Tabs.Screen name="collection" />
|
||||
<Tabs.Screen name="friends" />
|
||||
<Tabs.Screen name="profile" />
|
||||
</Tabs>
|
||||
<UpdateDialog />
|
||||
</>
|
||||
</NativeChatProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,11 +60,13 @@ function TabBar({
|
||||
routes,
|
||||
active,
|
||||
labels,
|
||||
badges,
|
||||
onPress,
|
||||
}: {
|
||||
routes: string[];
|
||||
active: number;
|
||||
labels: Record<string, string>;
|
||||
badges: Record<string, number>;
|
||||
onPress: (name: string) => void;
|
||||
}) {
|
||||
const colors = useColors();
|
||||
@@ -81,10 +90,13 @@ function TabBar({
|
||||
onPress={() => onPress(name)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: on }}
|
||||
accessibilityLabel={labels[name]}
|
||||
accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
|
||||
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
|
||||
>
|
||||
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||
<View>
|
||||
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||
{!!badges[name] && <Text style={{ position: 'absolute', top: -5, left: 14, minWidth: 16, paddingHorizontal: 4, borderRadius: 9, backgroundColor: colors['accent-fill'], color: '#fff', fontSize: 10, textAlign: 'center' }}>{badges[name]! > 99 ? '99+' : badges[name]}</Text>}
|
||||
</View>
|
||||
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
|
||||
{labels[name]}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SocialScreen } from '../../features/social/Social';
|
||||
export default function FriendsTab() { return <SocialScreen root />; }
|
||||
@@ -110,6 +110,9 @@ function Screens() {
|
||||
<Stack.Screen name="album/[id]" />
|
||||
<Stack.Screen name="artist/[id]" />
|
||||
<Stack.Screen name="playlists" />
|
||||
<Stack.Screen name="social" />
|
||||
<Stack.Screen name="social-track" />
|
||||
<Stack.Screen name="social-playlist" />
|
||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { MusicShareSheet } from '../features/social/Social';
|
||||
import { ListeningBanner } from '../features/social/Listening';
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
|
||||
import { useSessionCtx, useUser } from '../lib/session';
|
||||
@@ -21,6 +23,7 @@ export default function PlayerScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const prefs = useUser().ui_prefs;
|
||||
const { server } = useSessionCtx();
|
||||
const [sharing, setSharing] = useState(false);
|
||||
@@ -39,20 +42,12 @@ export default function PlayerScreen() {
|
||||
const duration = st.durationMs || track.duration_ms;
|
||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const share = async () => {
|
||||
if (!server || sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
||||
} catch {
|
||||
Alert.alert(t('player.share'), t('player.share_error'));
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
const share = () => setSharing(true);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
|
||||
<ListeningBanner />
|
||||
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
||||
@@ -102,7 +97,7 @@ export default function PlayerScreen() {
|
||||
<View style={{ gap: 6 }}>
|
||||
<Pressable
|
||||
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
|
||||
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
|
||||
disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityLabel={t('player.progress')}
|
||||
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
|
||||
@@ -124,18 +119,18 @@ export default function PlayerScreen() {
|
||||
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
|
||||
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||
<Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||
<Icon name="skip-previous" size={40} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => player.toggle()}
|
||||
disabled={locked} onPress={() => player.toggle()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
|
||||
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||
<Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||
<Icon name="skip-next" size={40} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { MusicShareSheet } from '../features/social/Social';
|
||||
import { playlistShareUrl, unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
|
||||
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||
@@ -77,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
|
||||
}
|
||||
|
||||
function Detail({ id }: { id: string }) {
|
||||
const [shareToken, setShareToken] = useState<string | null>(null);
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const user = useUser();
|
||||
@@ -94,7 +96,7 @@ function Detail({ id }: { id: string }) {
|
||||
if (!server || !p) throw new Error('Server unavailable');
|
||||
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
|
||||
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
|
||||
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
|
||||
setShareToken(token);
|
||||
} });
|
||||
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
|
||||
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
|
||||
@@ -112,7 +114,7 @@ function Detail({ id }: { id: string }) {
|
||||
const move = (index: number, delta: number) => {
|
||||
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||
};
|
||||
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||
return <Screen>{shareToken && server && p && <MusicShareSheet attachment={{ kind: 'playlist', id: shareToken, title: p.name }} url={playlistShareUrl(server, shareToken)} close={() => setShareToken(null)} />}<FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SharedMusic } from '../features/social/SharedMusic';
|
||||
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SharedMusic } from '../features/social/SharedMusic';
|
||||
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
|
||||
@@ -0,0 +1 @@
|
||||
export { SocialScreen as default } from '../features/social/Social';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { eventId } from '../../player/eventId';
|
||||
import { useColors } from '../../theme';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||
import { readDraft, saveDraft } from './drafts';
|
||||
|
||||
function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) {
|
||||
const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current;
|
||||
useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]);
|
||||
return <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
|
||||
}
|
||||
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) {
|
||||
const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText();
|
||||
const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState('');
|
||||
const [reply,setReply]=useState<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true);
|
||||
const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null);
|
||||
useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]);
|
||||
useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]);
|
||||
useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]);
|
||||
useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]);
|
||||
useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]);
|
||||
useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]);
|
||||
const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false});
|
||||
const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false});
|
||||
const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read);
|
||||
useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]);
|
||||
const refresh=()=>cache.invalidateQueries({queryKey});
|
||||
const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh});
|
||||
const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}});
|
||||
const scroll=useRef<ScrollView>(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]);
|
||||
const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);};
|
||||
const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);};
|
||||
return <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
|
||||
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Muted>{t(person.online ? 'social.online' : 'social.offline')}</Muted><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
|
||||
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
|
||||
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
|
||||
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
|
||||
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
|
||||
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
|
||||
<Bubble mine={m.sender_id===userId} animate={!reduced}>
|
||||
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
|
||||
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
|
||||
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
|
||||
</Pressable>
|
||||
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
|
||||
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
|
||||
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{new Date(m.created_at).toLocaleTimeString(i18n.language,{hour:'2-digit',minute:'2-digit'})}{m.edited_at&&!m.deleted?` · ${t('social.edited')}`:''}{m.sender_id===userId?` · ${t(m.read?'social.read':'social.sent')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
|
||||
</Bubble>
|
||||
</View>)}
|
||||
</ScrollView>
|
||||
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
|
||||
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
|
||||
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
|
||||
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
|
||||
</KeyboardAvoidingView>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { player } from '../../player/controller';
|
||||
import { eventId } from '../../player/eventId';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { useColors } from '../../theme';
|
||||
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||
|
||||
function useListening() {
|
||||
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
|
||||
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
|
||||
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||
return { api, key, query, user };
|
||||
}
|
||||
export function ListeningPanel() {
|
||||
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
|
||||
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
|
||||
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
|
||||
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
|
||||
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
|
||||
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
|
||||
</View>}
|
||||
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
|
||||
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
|
||||
</View>;
|
||||
}
|
||||
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
|
||||
return <><Button title={t('social.together')} disabled={!person.online} variant="ghost" onPress={() => setOpen(true)} />{!person.online && <Muted>{t('social.together_offline')}</Muted>}{open && person.online && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
|
||||
}
|
||||
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
|
||||
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
|
||||
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
|
||||
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
|
||||
</ScrollView></Screen></Modal>;
|
||||
}
|
||||
export function ListeningBanner() {
|
||||
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
|
||||
if (!room) return null;
|
||||
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
|
||||
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
|
||||
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
|
||||
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
|
||||
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
|
||||
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||
</View>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { player } from '../../player/controller';
|
||||
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
|
||||
|
||||
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
|
||||
const { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const errorText = useErrorText();
|
||||
const [playError, setPlayError] = useState<unknown>();
|
||||
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
|
||||
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
|
||||
if (kind === 'track') {
|
||||
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
|
||||
return { id: track.id, name: track.title, tracks: [track] };
|
||||
}
|
||||
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
|
||||
} });
|
||||
const visible = query.isError ? undefined : query.data;
|
||||
return <Screen><FlatList data={visible?.tracks ?? []} keyExtractor={x => x.id}
|
||||
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
|
||||
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
|
||||
<H1>{visible?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
|
||||
{query.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
|
||||
</View>}
|
||||
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
|
||||
/></Screen>;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { eventId } from '../../player/eventId';
|
||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { useColors } from '../../theme';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||
|
||||
function useSocial() {
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const user = useUser();
|
||||
const api = useMemo(() => socialApi(session), [session]);
|
||||
const [active, setActive] = useState(AppState.currentState === 'active');
|
||||
useEffect(() => { const sub = AppState.addEventListener('change', s => setActive(s === 'active')); return () => sub.remove(); }, []);
|
||||
const key = ['m-social', server, user.id] as const;
|
||||
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, enabled: active, refetchInterval: active ? 15_000 : false });
|
||||
return { api, key, inbox, user, active };
|
||||
}
|
||||
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||
|
||||
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
|
||||
export function NativeChatProvider({ children }: { children: ReactNode }) {
|
||||
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
|
||||
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
|
||||
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
|
||||
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
|
||||
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
|
||||
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
|
||||
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
|
||||
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
|
||||
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
|
||||
</Screen>
|
||||
</View></View>
|
||||
</Modal>
|
||||
</ChatDockContext.Provider>;
|
||||
}
|
||||
|
||||
export function FriendRecommendations() {
|
||||
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
|
||||
}
|
||||
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { inbox, api, key } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [sent, setSent] = useState<string[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const ids = useRef(new Map<string, string>());
|
||||
const send = useMutation({ mutationFn: async (peer: string) => {
|
||||
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
|
||||
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
|
||||
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
|
||||
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
|
||||
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
|
||||
} });
|
||||
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 24, gap: 16 }}>
|
||||
<Button title={t('form.cancel')} variant="ghost" onPress={close} />
|
||||
<H1>{t('player.share')}</H1><Muted>{attachment.title}</Muted>
|
||||
<Button title={t(copied ? 'social.copied' : 'social.copy')} disabled={send.isPending} onPress={() => send.mutate('copy')} />
|
||||
<Button title={t('social.external')} variant="secondary" disabled={send.isPending} onPress={() => send.mutate('external')} />
|
||||
<H1>{t('social.send_friend')}</H1>
|
||||
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <Muted>{t('social.no_friends_share')}</Muted>}
|
||||
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} title={`${p.display_name} · ${t(sent.includes(p.id) ? 'social.sent' : 'social.send')}`} variant="secondary" disabled={send.isPending || sent.includes(p.id)} onPress={() => send.mutate(p.id)} />)}
|
||||
<ErrorBox>{send.error || inbox.error ? errorText(send.error ?? inbox.error) : null}</ErrorBox>
|
||||
</ScrollView></Screen></Modal>;
|
||||
}
|
||||
|
||||
export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
const dock = useContext(ChatDockContext);
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const { api, inbox, key } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [search, setSearch] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [peer, setPeer] = useState('');
|
||||
const [notifications, setNotifications] = useState(false);
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
|
||||
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
|
||||
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
|
||||
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
|
||||
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
|
||||
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
|
||||
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||
<Muted>{t('social.activity_visibility')}</Muted>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
|
||||
<Muted>{t('social.activity_privacy_hint')}</Muted>
|
||||
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
|
||||
</View>
|
||||
<ListeningPanel />
|
||||
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
|
||||
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
|
||||
</View>
|
||||
<ErrorBox>{inbox.error || results.error || act.error ? errorText(inbox.error ?? results.error ?? act.error) : null}</ErrorBox>
|
||||
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||
{notifications ? <>
|
||||
{inbox.data?.notifications.length === 0 && <Muted>{t('social.no_notifications')}</Muted>}
|
||||
{inbox.data?.notifications.map(n => <View key={n.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10, borderWidth: 1, borderColor: n.read ? colors.line : colors.accent }}>
|
||||
<Text style={{ color: colors.text, fontWeight: '600' }}>{n.actor_name}</Text><Muted>{t(`social.kind_${n.kind}`)}</Muted><Muted>{new Date(n.created_at).toLocaleString()}</Muted>
|
||||
<Button title={t('social.open')} variant="secondary" onPress={() => { setNotifications(false); setSearch(''); setQ(''); setPeer(n.actor_id); }} />
|
||||
{!n.read && <Button title={t('social.mark_read')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'read_notification', target_id: n.id })} />}
|
||||
</View>)}
|
||||
</> : <>
|
||||
<Field label={t('social.search')} value={search} onChangeText={setSearch} maxLength={80} />
|
||||
{q.length >= 2 && results.isFetching && <Muted>{t('social.loading')}</Muted>}
|
||||
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
|
||||
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
|
||||
{p.relationship === 'friend' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t(p.online ? 'social.online' : 'social.offline')}</Text>}
|
||||
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
|
||||
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
|
||||
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
|
||||
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
|
||||
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
|
||||
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
|
||||
</View>}
|
||||
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
|
||||
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
|
||||
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
|
||||
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
|
||||
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
|
||||
<Button title={t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: p.relationship === 'blocked' ? 'unblock' : 'block', target_id: p.id })} />
|
||||
</View>)}
|
||||
</>}
|
||||
</ScrollView></Screen>;
|
||||
}
|
||||
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
|
||||
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
|
||||
const [focused,setFocused]=useState(false);
|
||||
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
|
||||
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { sha256 } from 'js-sha256';
|
||||
import { chatDraftKey } from '@pipu/core';
|
||||
|
||||
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
|
||||
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
|
||||
export async function readDraft(server: string, user: string, peer: string) {
|
||||
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
|
||||
}
|
||||
export async function saveDraft(server: string, user: string, peer: string, text: string) {
|
||||
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
|
||||
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import {
|
||||
EventBuffer,
|
||||
createActivityPublisher,
|
||||
type ActivityPublisher,
|
||||
ListeningCoordinator,
|
||||
type ListeningRoom,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveInfo,
|
||||
@@ -32,6 +36,7 @@ export { offlineWave };
|
||||
export type { WaveInfo } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
listening?: import('@pipu/core').ListeningClientState;
|
||||
ready: boolean;
|
||||
track: Track | null;
|
||||
queue: Track[];
|
||||
@@ -72,6 +77,11 @@ async function quality(): Promise<Quality> {
|
||||
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
|
||||
*/
|
||||
class Controller {
|
||||
private activity: ActivityPublisher | null = null;
|
||||
private listening: ListeningCoordinator | null = null;
|
||||
private interrupted = false;
|
||||
private remoteRoom: string | null = null;
|
||||
private queueVersion = 0;
|
||||
private session: AuthSession | null = null;
|
||||
private server = '';
|
||||
private events: EventBuffer | null = null;
|
||||
@@ -104,12 +114,27 @@ class Controller {
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.activity?.update(this.state);
|
||||
this.listening?.update();
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
|
||||
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
|
||||
attach(session: AuthSession | null, server: string, userId: string | null) {
|
||||
if (this.session === session) return;
|
||||
this.activity?.dispose();
|
||||
this.listening?.dispose();
|
||||
this.listening = null;
|
||||
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
|
||||
if (session && this.activity) {
|
||||
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
|
||||
read: this.getState,
|
||||
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
|
||||
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||
changed: listening => this.update({ listening }),
|
||||
});
|
||||
this.listening.start();
|
||||
}
|
||||
void this.events?.flush();
|
||||
this.session = session;
|
||||
this.server = server;
|
||||
@@ -240,35 +265,49 @@ class Controller {
|
||||
}
|
||||
|
||||
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||
if (this.listening?.locked) return;
|
||||
const version = ++this.queueVersion;
|
||||
const current = () => version === this.queueVersion && !this.listening?.locked;
|
||||
const list = tracks.filter(playable);
|
||||
const target = tracks[index];
|
||||
if (!target || !list.length) return;
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
const native = await this.toRntp(list);
|
||||
if (!current()) return;
|
||||
this.leaveWave();
|
||||
this.finishAttempt(this.lastPositionMs / 1000);
|
||||
const start = Math.max(list.indexOf(target), 0);
|
||||
this.update({ queue: list, context, index: start, track: list[start] ?? null });
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(await this.toRntp(list));
|
||||
if (!current()) return;
|
||||
await TrackPlayer.add(native);
|
||||
if (!current()) return;
|
||||
await TrackPlayer.skip(start);
|
||||
if (!current()) return;
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.listening?.locked) return;
|
||||
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.listening?.locked) return;
|
||||
void TrackPlayer.skipToNext().catch(() => undefined);
|
||||
}
|
||||
|
||||
async prev() {
|
||||
if (this.listening?.locked) return;
|
||||
const { position } = await TrackPlayer.getProgress();
|
||||
if (this.listening?.locked) return;
|
||||
if (position > 3) await TrackPlayer.seekTo(0);
|
||||
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
|
||||
}
|
||||
|
||||
seek(ms: number) {
|
||||
if (this.listening?.locked) return;
|
||||
const t = this.state.track;
|
||||
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
|
||||
this.attempt?.seek(ms);
|
||||
@@ -306,6 +345,7 @@ class Controller {
|
||||
// --- «Моя волна» ---
|
||||
|
||||
async startWave(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
if (!this.session) return;
|
||||
await this.setup();
|
||||
this.leaveWave();
|
||||
@@ -335,6 +375,7 @@ class Controller {
|
||||
}
|
||||
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
const wave = this.state.wave;
|
||||
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||
@@ -422,18 +463,28 @@ class Controller {
|
||||
|
||||
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
|
||||
async restore() {
|
||||
if (!this.session || this.state.track) return;
|
||||
if (!this.session || this.state.track || this.listening?.locked) return;
|
||||
const session = this.session;
|
||||
const initial = this.state;
|
||||
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
try {
|
||||
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
|
||||
if (!current()) return;
|
||||
if (!st.track_id || !st.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||
if (!current()) return;
|
||||
const tracks = res.items.filter(playable);
|
||||
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
|
||||
if (!tracks.length) return;
|
||||
const native = await this.toRntp(tracks);
|
||||
if (!current()) return;
|
||||
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(await this.toRntp(tracks));
|
||||
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||
await TrackPlayer.add(native);
|
||||
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
|
||||
} catch {
|
||||
// офлайн или нет сохранённого состояния
|
||||
@@ -441,6 +492,7 @@ class Controller {
|
||||
}
|
||||
|
||||
async reset() {
|
||||
this.queueVersion++;
|
||||
this.wavePolicy.activate(null);
|
||||
this.attempt = null;
|
||||
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||
@@ -451,6 +503,35 @@ class Controller {
|
||||
this.save();
|
||||
void this.events?.flush();
|
||||
}
|
||||
|
||||
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
|
||||
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
|
||||
setInterrupted(paused: boolean) { this.interrupted = paused; }
|
||||
beginListeningAudio() { this.listening?.beginAudio(); }
|
||||
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||
if (!room.track_id) { await TrackPlayer.pause(); return; }
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||
const native = await this.toRntp([track]);
|
||||
if (!current()) return;
|
||||
this.remoteRoom = room.id;
|
||||
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
|
||||
await TrackPlayer.pause();
|
||||
if (!current()) return;
|
||||
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
|
||||
await TrackPlayer.reset();
|
||||
if (!current()) return;
|
||||
await TrackPlayer.add(native);
|
||||
}
|
||||
if (!current()) return;
|
||||
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
|
||||
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
|
||||
if (!current()) { await TrackPlayer.pause(); return; }
|
||||
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
|
||||
else if (this.state.playing) await TrackPlayer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export const player = new Controller();
|
||||
@@ -7,14 +7,15 @@ import { player } from './controller';
|
||||
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
|
||||
*/
|
||||
export async function PlaybackService(): Promise<void> {
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
|
||||
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
|
||||
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
|
||||
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
|
||||
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
|
||||
player.setInterrupted(paused || permanent);
|
||||
if (permanent) void TrackPlayer.pause();
|
||||
else if (paused) void TrackPlayer.pause();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FriendRecommendations } from '../features/social/Social';
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||
@@ -38,6 +39,7 @@ export function RecommendationSettings() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const errorText = useErrorText();
|
||||
return <View>
|
||||
<FriendRecommendations />
|
||||
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
|
||||
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
|
||||
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
import { releaseNotesText } from '@pipu/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
@@ -10,7 +11,6 @@ import { Button } from './kit';
|
||||
export function useUpdateState() {
|
||||
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
||||
}
|
||||
|
||||
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
||||
export function UpdateDialog() {
|
||||
const colors = useColors();
|
||||
@@ -32,6 +32,7 @@ export function UpdateDialog() {
|
||||
|
||||
const { release, required } = st;
|
||||
const busy = st.status === 'downloading' || st.status === 'verifying';
|
||||
const notes = releaseNotesText(release.notes_md);
|
||||
const action = () => void updater.downloadAndInstall(session, server ?? '');
|
||||
|
||||
return (
|
||||
@@ -57,9 +58,9 @@ export function UpdateDialog() {
|
||||
{t('mobile.update_title', { version: release.version })}
|
||||
</Text>
|
||||
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
|
||||
{release.notes_md.trim() !== '' && (
|
||||
<ScrollView style={{ maxHeight: 240 }}>
|
||||
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
|
||||
{notes !== '' && (
|
||||
<ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
|
||||
<Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
|
||||
</ScrollView>
|
||||
)}
|
||||
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
||||
@@ -115,13 +116,3 @@ export function UpdateDialog() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
|
||||
function plainNotes(md: string): string {
|
||||
return md
|
||||
.replace(/^#{1,6}\s*/gm, '')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/^\s*[-*]\s+/gm, '• ')
|
||||
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
@@ -70,3 +70,18 @@ it('does not let a late context response replace a manually selected playlist',
|
||||
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
|
||||
expect(player.getState().context?.type).toBe('album');
|
||||
});
|
||||
|
||||
it('does not restore a stale saved queue after manual playback starts', async () => {
|
||||
const { player } = await import('../src/player/controller');
|
||||
const saved = deferred<unknown>();
|
||||
const get = vi.fn().mockReturnValue(saved.promise);
|
||||
const post = vi.fn();
|
||||
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||
const restoring = player.restore();
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalled());
|
||||
await player.playList([track('chosen')], 0, { type: 'queue' });
|
||||
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
|
||||
await restoring;
|
||||
expect(player.getState().track?.id).toBe('chosen');
|
||||
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function login(page: Page, username = 'mobile-admin', target = '/') {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||
await page.getByLabel('Логин', { exact: true }).fill(username);
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||
}
|
||||
|
||||
test('admin groups related pages and remembers collapsed sections', async ({ page }, info) => {
|
||||
await login(page);
|
||||
const nav = page.getByRole('navigation', { name: 'Навигация' });
|
||||
await nav.getByRole('button', { name: 'Админка', exact: true }).click();
|
||||
await expect(nav.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
|
||||
await expect(nav.getByRole('button', { name: title, exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||||
}
|
||||
await nav.getByRole('button', { name: 'Система', exact: true }).click();
|
||||
const system = nav.getByRole('group', { name: 'Система', exact: true });
|
||||
await expect(system.getByRole('link')).toHaveCount(4);
|
||||
for (const title of ['Обновления', 'Конфигурация', 'Настройки', 'Журнал']) {
|
||||
await expect(system.getByRole('link', { name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
await nav.screenshot({ path: info.outputPath('admin-groups.png') });
|
||||
// The fake API resets authentication on a full document load; sign in again.
|
||||
await login(page);
|
||||
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'true');
|
||||
await nav.getByRole('button', { name: 'Система', exact: true }).click();
|
||||
await login(page);
|
||||
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||||
await login(page, 'mobile-admin', '/admin/audit');
|
||||
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toBeVisible();
|
||||
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toHaveAttribute('data-status', 'active');
|
||||
await nav.getByRole('button', { name: 'Медиатека', exact: true }).click();
|
||||
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link')).toHaveCount(2);
|
||||
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
|
||||
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('moderator sees only permitted groups and pages', async ({ page }) => {
|
||||
await login(page, 'moderator', '/admin/library');
|
||||
const nav = page.getByRole('navigation', { name: 'Навигация' });
|
||||
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link', { name: 'Библиотека' })).toBeVisible();
|
||||
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveCount(0);
|
||||
await expect(nav.getByRole('link', { name: 'Хранилища' })).toHaveCount(0);
|
||||
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
|
||||
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile menu exposes grouped pages and closes after navigation', async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login(page);
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Навигация' });
|
||||
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
|
||||
await expect(dialog.getByRole('heading', { name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
await dialog.screenshot({ path: info.outputPath('admin-groups-mobile.png') });
|
||||
await dialog.getByRole('group', { name: 'Система', exact: true }).getByRole('link', { name: 'Обновления', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/admin\/updates$/);
|
||||
});
|
||||
|
||||
test('tablet icon sidebar keeps all admin destinations accessible', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 900, height: 900 });
|
||||
await login(page);
|
||||
const nav = page.getByRole('navigation', { name: 'Навигация' });
|
||||
for (const title of ['Библиотека', 'Хранилища', 'Предложить треки', 'Очередь', 'Журнал']) {
|
||||
await expect(nav.getByRole('link', { name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('desktop files survive restart, isolate owners and clear only recent cache', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
const saved = await page.evaluate(async () => {
|
||||
const source = '/src/features/desktop/offline.ts';
|
||||
const { DesktopOffline } = await import(source);
|
||||
const track = { id: 'cached-track', title: 'Offline song', available: true, stream_url: 'data:audio/wav;base64,UklGRg==', duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', cover_url: null }, genres: [], reaction: null };
|
||||
const session = { client: { POST: async () => ({ data: { items: [track] }, response: new Response('{}') }) } };
|
||||
const offline = new DesktopOffline('test-server/alice', session);
|
||||
await offline.ready;
|
||||
await offline.save(track, true);
|
||||
await offline.save({ ...track, id: 'recent-track' });
|
||||
const tracks = offline.tracks();
|
||||
const result = { count: tracks.length, blob: tracks[0].stream_url.startsWith('blob:'), pinned: offline.pinned(track.id) };
|
||||
offline.dispose();
|
||||
return result;
|
||||
});
|
||||
expect(saved).toEqual({ count: 2, blob: true, pinned: true });
|
||||
await page.reload();
|
||||
const restored = await page.evaluate(async () => {
|
||||
const source = '/src/features/desktop/offline.ts';
|
||||
const { DesktopOffline } = await import(source);
|
||||
const session = { client: { POST: async () => { throw new TypeError('offline'); } } };
|
||||
const alice = new DesktopOffline('test-server/alice', session);
|
||||
const bob = new DesktopOffline('test-server/bob', session);
|
||||
await Promise.all([alice.ready, bob.ready]);
|
||||
const before = alice.tracks().length;
|
||||
const body = await (await fetch(alice.tracks()[0].stream_url)).blob();
|
||||
await alice.clearRecent();
|
||||
const result = { before, bytes: body.size, after: alice.tracks().map((t: { id: string }) => t.id), other: bob.tracks().length };
|
||||
alice.dispose(); bob.dispose();
|
||||
return result;
|
||||
});
|
||||
expect(restored).toEqual({ before: 2, bytes: 4, after: ['cached-track'], other: 0 });
|
||||
});
|
||||
|
||||
test('desktop update dialog hides build reports and launches installer', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pipu.locale', 'ru');
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
|
||||
core: { invoke: async (command: string) => {
|
||||
if (command === 'bootstrap') return { proxyBase: location.origin, server: 'https://music.example' };
|
||||
if (command === 'update_check') return { build: { version: '1.8.0', size: 1048576 }, notes: '<!-- android-status -->\n> APK SHA-256 abc\n<!-- /android-status -->\n## Изменения\n- **Офлайн-музыка**' };
|
||||
if (command === 'update_install') document.documentElement.dataset.installer = 'opened';
|
||||
return null;
|
||||
} },
|
||||
event: { listen: async () => () => undefined },
|
||||
};
|
||||
});
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин').fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toContainText('Доступна версия 1.8.0');
|
||||
await expect(dialog).toContainText('• Офлайн-музыка');
|
||||
await expect(dialog).not.toContainText('android-status');
|
||||
await expect(dialog).not.toContainText('SHA-256');
|
||||
await dialog.getByRole('button', { name: 'Скачать и установить' }).click();
|
||||
await expect(dialog).toContainText('Установщик открыт');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-installer', 'opened');
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const scenario of ['pending', 'automatic', 'restricted', 'incoming']) {
|
||||
test(`listening request lifecycle: ${scenario}`, async ({ page }, info) => {
|
||||
await page.addInitScript(value => {
|
||||
localStorage.setItem('pipu.locale', 'ru');
|
||||
localStorage.setItem('pipu.e2e.listening', value);
|
||||
}, scenario);
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
|
||||
const banner = page.getByRole('region', { name: 'Слушать вместе', exact: true });
|
||||
await expect(banner).toHaveCount(0);
|
||||
if (scenario === 'incoming') {
|
||||
await expect(page.getByRole('button', { name: 'Принять', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('pending-invite.png'), fullPage: true });
|
||||
await page.getByRole('button', { name: 'Принять', exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('button', { name: 'Слушать вместе', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Слушать вместе', exact: true });
|
||||
await expect(dialog.getByRole('radio', { name: 'Подключиться к другу', exact: true })).toBeChecked();
|
||||
await dialog.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
if (scenario === 'restricted') {
|
||||
await expect(dialog.getByRole('alert')).toContainText('Совместное прослушивание недоступно');
|
||||
await expect(dialog.getByRole('alert')).not.toContainText('Чат доступен только друзьям');
|
||||
await page.screenshot({ path: info.outputPath('request-restricted.png'), fullPage: true });
|
||||
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(banner).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
await expect(dialog).toHaveCount(0);
|
||||
if (scenario === 'pending') {
|
||||
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toBeVisible();
|
||||
await expect(banner).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Отмена', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toHaveCount(0);
|
||||
await expect(banner).toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await expect(banner).toContainText('Слушаете вместе с moderator');
|
||||
await expect(page.getByRole('contentinfo', { name: 'Плеер', exact: true }).getByRole('button', { name: 'Следующий', exact: true })).toBeDisabled();
|
||||
await banner.getByRole('button', { name: 'Выйти из прослушивания', exact: true }).click();
|
||||
await expect(banner).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import axe from 'axe-core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const { width, height, light } of [{ width: 1280, height: 900, light: false }, { width: 1920, height: 900, light: false }, { width: 1024, height: 600, light: false }, { width: 1280, height: 720, light: true }]) {
|
||||
test(`mini chat keeps the page and draft at ${width}x${height} ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
if (width === 1920) {
|
||||
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Остров', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Остров', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('link', { name: 'Главная', exact: true }).first().click();
|
||||
}
|
||||
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||
const dock = page.getByRole('complementary', { name: 'Друзья и чат', exact: true });
|
||||
const toggle = dock.getByRole('button', { name: 'Друзья и чат', exact: true });
|
||||
await toggle.click();
|
||||
const panel = dock.getByRole('region', { name: 'Друзья и общение', exact: true });
|
||||
await panel.getByRole('textbox', { name: 'Найти диалог', exact: true }).fill('moderator');
|
||||
await expect(panel.getByRole('textbox', { name: 'Найти диалог', exact: true })).toHaveCSS('outline-style', 'none');
|
||||
await panel.getByRole('button', { name: /moderator/ }).click();
|
||||
const composer = panel.getByRole('textbox', { name: 'Сообщение', exact: true });
|
||||
await composer.fill('Черновик из мини-чата');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await panel.getByRole('button', { name: 'Свернуть чат', exact: true }).click();
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(toggle).toBeFocused();
|
||||
await page.getByRole('link', { name: 'История', exact: true }).first().click();
|
||||
await toggle.click();
|
||||
await expect(composer).toHaveValue('Черновик из мини-чата');
|
||||
await panel.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
await expect(panel.getByText('Черновик из мини-чата', { exact: true })).toBeVisible();
|
||||
await expect(composer).toBeEmpty();
|
||||
await expect(page).toHaveURL(/\/collection\/history$/);
|
||||
const panelBox = (await panel.boundingBox())!;
|
||||
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер', exact: true }).boundingBox())!;
|
||||
expect(panelBox.width).toBeLessThanOrEqual(600);
|
||||
expect(panelBox.x).toBeGreaterThanOrEqual(width - 640);
|
||||
expect(panelBox.y).toBeGreaterThanOrEqual(60);
|
||||
expect(panelBox.y + panelBox.height).toBeLessThan(playerBox.y);
|
||||
await panel.evaluate(async element => {
|
||||
await Promise.all(element.getAnimations({ subtree: true })
|
||||
.filter(animation => animation.effect?.getTiming().iterations !== Infinity)
|
||||
.map(animation => animation.finished.catch(() => undefined)));
|
||||
});
|
||||
await page.addScriptTag({ content: axe.source });
|
||||
const violations = await page.evaluate(async (selector) => {
|
||||
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
|
||||
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => ({ target: node.target, summary: node.failureSummary })) }));
|
||||
}, '[id="mini-social-panel"]');
|
||||
expect(violations).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath('mini-chat.png'), fullPage: true });
|
||||
await composer.fill('Продолжить в полном чате');
|
||||
await composer.press('Escape');
|
||||
await expect(panel).toBeHidden();
|
||||
await toggle.click();
|
||||
await expect(composer).toHaveValue('Продолжить в полном чате');
|
||||
await page.setViewportSize({ width: 800, height: 900 });
|
||||
await expect(dock).toHaveCount(0);
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect(composer).toHaveValue('Продолжить в полном чате');
|
||||
await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/friends\?/);
|
||||
await expect(dock).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveValue('Продолжить в полном чате');
|
||||
});
|
||||
}
|
||||
@@ -92,6 +92,31 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
test(`player artist opens without a browser URL preview at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login(page, 'alice');
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
|
||||
await box.fill('кино');
|
||||
await box.press('Enter');
|
||||
await page.getByRole('link', { name: /Группа крови/ }).first().click();
|
||||
await page.getByRole('listitem').first().click();
|
||||
const artist = page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('link', { name: 'Кино', exact: true });
|
||||
await artist.hover();
|
||||
await expect(artist).not.toHaveAttribute('href');
|
||||
expect(await artist.evaluate((el) => Boolean(el.closest('[title], a[href]')))).toBe(false);
|
||||
if (width === 390) {
|
||||
await artist.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
} else {
|
||||
await artist.click();
|
||||
}
|
||||
await expect(page).toHaveURL(/\/artist\//);
|
||||
await expect(page.getByRole('heading', { name: 'Кино', exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 320, height: 740 });
|
||||
await login(page, 'alice');
|
||||
@@ -102,7 +127,15 @@ test('mobile player keeps the island above navigation and exposes the queue', as
|
||||
await page.getByRole('listitem').first().click();
|
||||
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
|
||||
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
|
||||
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(220);
|
||||
const artist = player.getByRole('link', { name: 'Кино', exact: true });
|
||||
await expect(artist).toBeVisible();
|
||||
const artistArea = await artist.locator('..').boundingBox();
|
||||
expect(artistArea!.width).toBeGreaterThan(220);
|
||||
const trackInfo = await player.getByText('Группа крови', { exact: true }).boundingBox();
|
||||
const play = await player.getByRole('button', { name: /^(Пауза|Слушать)$/ }).boundingBox();
|
||||
expect(trackInfo!.y + trackInfo!.height).toBeLessThanOrEqual(play!.y);
|
||||
await page.screenshot({ path: info.outputPath('player-readable.png') });
|
||||
await fits(page);
|
||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import axe from 'axe-core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
test(`notification history scrolls inside the dialog at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pipu.locale', 'ru');
|
||||
localStorage.setItem('pipu.e2e.notifications', '1');
|
||||
});
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Уведомления 4', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Уведомления', exact: true });
|
||||
const list = dialog.getByRole('region', { name: 'Уведомления', exact: true });
|
||||
await expect(list.getByRole('article')).toHaveCount(28);
|
||||
const box = (await dialog.boundingBox())!;
|
||||
expect(box.height).toBeLessThanOrEqual(680);
|
||||
expect(box.width).toBeLessThanOrEqual(Math.min(600, width - (width < 768 ? 24 : 32)));
|
||||
const close = dialog.getByRole('button', { name: 'Закрыть', exact: true });
|
||||
const closeBefore = (await close.boundingBox())!;
|
||||
await list.evaluate(element => { element.scrollTop = element.scrollHeight; });
|
||||
await expect(close).toBeVisible();
|
||||
expect((await close.boundingBox())!.y).toBe(closeBefore.y);
|
||||
await list.evaluate(element => { element.scrollTop = 0; });
|
||||
await page.addScriptTag({ content: axe.source });
|
||||
const violations = await page.evaluate(async (selector) => {
|
||||
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
|
||||
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
|
||||
}, '[role="dialog"]');
|
||||
expect(violations).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath('notifications.png'), fullPage: true });
|
||||
await dialog.getByRole('button', { name: 'Непрочитанные 4', exact: true }).click();
|
||||
await expect(list.getByRole('article')).toHaveCount(4);
|
||||
await list.getByRole('button', { name: 'Прочитано', exact: true }).first().click();
|
||||
await expect(list.getByRole('article')).toHaveCount(3);
|
||||
await dialog.getByRole('button', { name: 'Все', exact: true }).click();
|
||||
await expect(list.getByRole('article')).toHaveCount(28);
|
||||
await close.click();
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
}
|
||||
@@ -32,9 +32,13 @@ for (const width of [320, 1280]) {
|
||||
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
|
||||
const shared = new URL(sharedUrl);
|
||||
expect(shared.origin).toBe(new URL(page.url()).origin);
|
||||
expect(shared.pathname).toMatch(/^\/shared\/playlist\/[a-f0-9-]+$/);
|
||||
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
|
||||
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
@@ -45,7 +49,9 @@ for (const width of [320, 1280]) {
|
||||
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function login(page: Page, username = 'mobile-admin', target = '/') {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||
await page.getByLabel('Логин').fill(username);
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`);
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||
}
|
||||
|
||||
test('job log follows new output and pauses while reading older lines', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.evaluate(() => {
|
||||
let lines = 100;
|
||||
window.addEventListener('test-job-lines', (event) => { lines = (event as CustomEvent<number>).detail; });
|
||||
const original = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const response = await original(input, init);
|
||||
const request = new Request(input, init);
|
||||
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/updates') {
|
||||
const data = await response.json();
|
||||
data.job = {
|
||||
id: 'scroll-job', kind: 'update', state: 'running', target: 'main',
|
||||
started_at: '2026-10-22T09:00:00Z', finished_at: '',
|
||||
log: Array.from({ length: lines }, (_, i) => `Build line ${i + 1}`).join('\n'),
|
||||
};
|
||||
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return response;
|
||||
};
|
||||
history.pushState({}, '', '/admin/updates');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
const log = page.getByLabel('Журнал выполнения');
|
||||
const bottomGap = () => log.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
|
||||
await expect(log).toContainText('Build line 100');
|
||||
expect(await log.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true);
|
||||
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
|
||||
|
||||
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 120 })));
|
||||
await expect(log).toContainText('Build line 120');
|
||||
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
|
||||
|
||||
await log.evaluate((el) => { el.scrollTop = 100; });
|
||||
await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(100);
|
||||
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 140 })));
|
||||
await expect(log).toContainText('Build line 140');
|
||||
expect(await log.evaluate((el) => el.scrollTop)).toBe(100);
|
||||
|
||||
await log.evaluate((el) => { el.scrollTop = el.scrollHeight; });
|
||||
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
|
||||
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 160 })));
|
||||
await expect(log).toContainText('Build line 160');
|
||||
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/updates');
|
||||
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Добавить источник' }).click();
|
||||
await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа');
|
||||
await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git');
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' });
|
||||
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
|
||||
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
|
||||
await page.getByRole('button', { name: 'Установить обновление', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toContainText('Тестовая репа');
|
||||
await expect(dialog).toContainText('v1.10.0');
|
||||
await dialog.getByRole('button', { name: 'Установить обновление' }).click();
|
||||
await expect(page.getByRole('status').filter({ hasText: /^Выполняется$/ })).toBeVisible();
|
||||
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/config');
|
||||
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
|
||||
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
|
||||
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
|
||||
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
|
||||
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
|
||||
await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
|
||||
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
|
||||
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
|
||||
await page.screenshot({ path: 'test-results/server-config.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click();
|
||||
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
|
||||
});
|
||||
|
||||
test('update delegate can update but cannot open configuration', async ({ page }) => {
|
||||
await login(page, 'update-manager', '/admin/updates');
|
||||
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
|
||||
await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); });
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('configuration masks every stored secret even if an older API returns plaintext', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.evaluate(() => {
|
||||
const original = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const response = await original(input, init);
|
||||
const request = new Request(input, init);
|
||||
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/config') {
|
||||
const data = await response.json();
|
||||
const common = { group: 'api', type: 'string', secret: true, readonly: false };
|
||||
data.entries = [
|
||||
{ ...common, key: 'ENV_SECRET', env: 'env-canary', local: 'local-canary', effective: 'env-canary', default_value: 'default-canary', source: 'env' },
|
||||
{ ...common, key: 'DEFAULT_SECRET', env: null, local: null, effective: 'default-canary', default_value: 'default-canary', source: 'default' },
|
||||
{ ...common, key: 'READONLY_SECRET', readonly: true, env: 'readonly-canary', local: null, effective: 'readonly-canary', default_value: '', source: 'env' },
|
||||
];
|
||||
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return response;
|
||||
};
|
||||
history.pushState({}, '', '/admin/config');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
const env = page.getByLabel('ENV_SECRET · config.local', { exact: true });
|
||||
const defaults = page.getByLabel('DEFAULT_SECRET · config.local', { exact: true });
|
||||
await expect(env).toHaveValue('');
|
||||
await expect(env).toHaveAttribute('type', 'password');
|
||||
await expect(defaults).toHaveValue('');
|
||||
await expect(defaults).toHaveAttribute('placeholder', '');
|
||||
await expect(page.getByText('READONLY_SECRET', { exact: true })).toBeVisible();
|
||||
for (const canary of ['env-canary', 'local-canary', 'default-canary', 'readonly-canary']) {
|
||||
expect(await page.getByRole('main').innerHTML()).not.toContain(canary);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Показать пароль', exact: true }).first().click();
|
||||
await expect(env).toHaveValue('');
|
||||
});
|
||||
|
||||
test('a replacement secret is hidden and cleared from the form after saving', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/config');
|
||||
const token = page.getByLabel('GITEA_TOKEN · config.local', { exact: true });
|
||||
await token.fill('replacement-canary');
|
||||
await expect(token).toHaveAttribute('type', 'password');
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
|
||||
await expect(token).toHaveValue('');
|
||||
await expect(token).toHaveAttribute('placeholder', 'Секрет сохранён');
|
||||
expect(await page.getByRole('main').innerHTML()).not.toContain('replacement-canary');
|
||||
});
|
||||
|
||||
test('ordinary user is redirected away from server management', async ({ page }) => {
|
||||
await login(page, 'alice', '/admin/updates');
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
|
||||
|
||||
test('installed main and release show no install action; another version can be installed', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/updates');
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'main', exact: true }).click();
|
||||
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
|
||||
await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible();
|
||||
await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0');
|
||||
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
|
||||
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
|
||||
await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/updates');
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const original = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const response = await original(input, init);
|
||||
const data = await response.json();
|
||||
return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
});
|
||||
await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click();
|
||||
await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
|
||||
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('saving panel disappears when all edited values return to their original state', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/config');
|
||||
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
|
||||
const save = page.getByRole('button', { name: 'Сохранить', exact: true });
|
||||
const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true });
|
||||
await expect(save).not.toBeVisible();
|
||||
await expect(apply).not.toBeVisible();
|
||||
const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true });
|
||||
await field.fill('6');
|
||||
await expect(save).toBeVisible();
|
||||
await field.clear();
|
||||
await expect(save).not.toBeVisible();
|
||||
await expect(apply).not.toBeVisible();
|
||||
await field.fill('7');
|
||||
await page.getByRole('button', { name: 'Отменить правки', exact: true }).click();
|
||||
await expect(save).not.toBeVisible();
|
||||
await expect(field).toHaveValue('');
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import axe from 'axe-core';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const { width, light } of [{ width: 390, light: false }, { width: 1280, light: false }, { width: 1280, light: true }]) {
|
||||
test(`social profile settings at ${width}px ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Уведомления', exact: true })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Моя музыка скрыта', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Приватность и общение', exact: true });
|
||||
const visibility = dialog.getByRole('group', { name: 'Кто видит мою музыку', exact: true });
|
||||
const friends = visibility.getByRole('button', { name: 'Друзьям', exact: true });
|
||||
await friends.click();
|
||||
await expect(friends).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(friends).toBeEnabled();
|
||||
await expect(friends).toBeFocused();
|
||||
await dialog.getByText('Слушать вместе', { exact: true }).click();
|
||||
await expect(dialog.getByRole('group', { name: 'Принимать запросы и приглашения от', exact: true })).toBeVisible();
|
||||
expect((await dialog.boundingBox())!.width).toBeLessThanOrEqual(width - 24);
|
||||
await expect.poll(() => dialog.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await page.addScriptTag({ content: axe.source });
|
||||
const violations = await page.evaluate(async () => {
|
||||
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[role="dialog"]')!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
|
||||
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath('privacy.png'), fullPage: true });
|
||||
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
|
||||
const heading = page.getByRole('heading', { name: 'Друзья и общение', exact: true });
|
||||
await expect(heading).toBeVisible();
|
||||
expect((await heading.locator('..').boundingBox())!.height).toBeLessThanOrEqual(40);
|
||||
await expect(page.getByRole('button', { name: 'Моя музыка скрыта', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Уведомления', exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const width of [320, 800, 1280]) {
|
||||
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: width === 1280 ? 'no-preference' : 'reduce' });
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
const openFriends = async () => {
|
||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
|
||||
};
|
||||
await openFriends();
|
||||
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||
await page.getByRole('button', { name: /moderator Начать разговор/ }).click();
|
||||
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByText('Не в сети', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByRole('button', { name: 'Слушать вместе', exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole('region', { name: 'Написать', exact: true })).toHaveCSS('animation-name', width === 1280 ? /conversationIn/ : 'none');
|
||||
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
|
||||
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
|
||||
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
|
||||
await page.getByRole('button', { name: 'Действия с сообщением', exact: true }).click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Удалить для всех', exact: true })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: '❤️', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '❤️ 1', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||
await page.getByRole('listitem').first().click();
|
||||
await page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('button', { name: 'Поделиться треком', exact: true }).click();
|
||||
await expect(page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true })).toBeVisible();
|
||||
if (width === 800) {
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Отправить в приложение', exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('shared-url'))).toContain('/track/');
|
||||
}
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'moderator · Отправить', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'moderator · Отправлено', exact: true })).toBeDisabled();
|
||||
await page.screenshot({ path: info.outputPath('share-music.png') });
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
|
||||
await openFriends();
|
||||
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
|
||||
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Музыка из переписки', exact: true }).click();
|
||||
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: info.outputPath('shared-music.png'), fullPage: true });
|
||||
await page.getByRole('button', { name: 'Переписка', exact: true }).click();
|
||||
const longMessage = '%20%D0%BC%D1%83%D0%B7%D1%8B%D0%BA%D0%B0'.repeat(45);
|
||||
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill(longMessage);
|
||||
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Показать полностью', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Показать полностью', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Свернуть текст', exact: true }).locator('..')).toContainText(longMessage);
|
||||
await page.getByRole('button', { name: 'Свернуть текст', exact: true }).click();
|
||||
if (width === 320) {
|
||||
await page.getByRole('button', { name: 'Назад к сообщениям', exact: true }).click();
|
||||
await expect(page.getByRole('searchbox', { name: 'Найти диалог', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeHidden();
|
||||
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
|
||||
await page.setViewportSize({ width, height: 560 });
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
|
||||
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
|
||||
return composerBox.y + composerBox.height - playerBox.y;
|
||||
}).toBeLessThanOrEqual(0);
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
})).toBeLessThanOrEqual(1);
|
||||
await page.getByRole('link', { name: 'Открыть трек', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Группа крови', exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const nginxHeaders = readFileSync(new URL('../../../deploy/nginx/security-headers.conf', import.meta.url), 'utf8');
|
||||
const desktopConfig = JSON.parse(readFileSync(new URL('../../desktop/src-tauri/tauri.conf.json', import.meta.url), 'utf8'));
|
||||
const policies: Record<string, string> = {
|
||||
web: nginxHeaders.match(/add_header Content-Security-Policy "([^"]+)" always;/)![1],
|
||||
desktop: desktopConfig.app.security.csp,
|
||||
};
|
||||
|
||||
for (const [platform, policy] of Object.entries(policies)) {
|
||||
test(`upload hash worker runs under ${platform} CSP`, async ({ page }) => {
|
||||
// Apply the deployed policy to both the document and worker responses.
|
||||
await page.route('**/*', async (route) => {
|
||||
if (new URL(route.request().url()).pathname === '/csp-upload-test') {
|
||||
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Upload CSP</title>', headers: { 'Content-Security-Policy': policy } });
|
||||
return;
|
||||
}
|
||||
if (new URL(route.request().url()).pathname === '/csp-eval-test.js') {
|
||||
await route.fulfill({ contentType: 'application/javascript', body: `
|
||||
try { new Function('return 1')(); document.documentElement.dataset.evalBlocked = 'false'; }
|
||||
catch { document.documentElement.dataset.evalBlocked = 'true'; }
|
||||
` });
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } });
|
||||
});
|
||||
await page.goto('/csp-upload-test');
|
||||
const result = await page.evaluate(() => new Promise((resolve, reject) => {
|
||||
const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' });
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.onmessage = ({ data }) => {
|
||||
if (data.type === 'progress') return;
|
||||
worker.terminate();
|
||||
resolve(data);
|
||||
};
|
||||
worker.postMessage({ id: 'csp-test', file: new File(['abc'], 'track.flac') });
|
||||
}));
|
||||
expect(result).toEqual({
|
||||
id: 'csp-test', type: 'done',
|
||||
sha256: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
|
||||
});
|
||||
// A page script observes CSP; Playwright's evaluate bypasses the eval restriction.
|
||||
await page.addScriptTag({ url: '/csp-eval-test.js' });
|
||||
await expect(page.locator('html')).toHaveAttribute('data-eval-blocked', 'true');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function login(page: Page, username = 'mobile-admin', target = '/admin/users') {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||
await page.getByLabel('Логин', { exact: true }).fill(username);
|
||||
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const width of [320, 1280]) {
|
||||
test(`user transfer and cover counts at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await login(page);
|
||||
await page.evaluate(() => {
|
||||
const original = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
const path = new URL(req.url).pathname;
|
||||
if (path === '/api/v1/admin/users/export') return new Response(JSON.stringify({
|
||||
version: 1, users: [{ username: 'alice', role: 'user', password_hash: '$argon2id$test' }],
|
||||
}), { headers: { 'Content-Type': 'application/json' } });
|
||||
if (path === '/api/v1/admin/users/import') {
|
||||
const archive = await req.json();
|
||||
if (archive.users[0].password_hash !== '$argon2id$test') throw new Error('lost hash');
|
||||
return new Response(JSON.stringify({ created: 1, skipped: ['alice'] }), { headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
});
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Экспорт пользователей', exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('pipu-users.json');
|
||||
await page.getByRole('button', { name: 'Импорт пользователей', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Импорт пользователей' });
|
||||
const file = dialog.getByLabel('Файл пользователей (.json)');
|
||||
await file.setInputFiles({ name: 'bad.json', mimeType: 'application/json', buffer: Buffer.from('{}') });
|
||||
await expect(dialog.getByRole('alert')).toContainText('Некорректный файл');
|
||||
await expect(dialog.getByRole('button', { name: 'Импортировать', exact: true })).toBeDisabled();
|
||||
await file.setInputFiles({ name: 'users.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({
|
||||
version: 1, users: [{ username: 'new-user', role: 'moderator', password_hash: '$argon2id$test' }],
|
||||
})) });
|
||||
await expect(dialog).toContainText('Учётных записей в файле: 1');
|
||||
await page.screenshot({ path: info.outputPath('user-import.png') });
|
||||
await dialog.getByRole('button', { name: 'Импортировать', exact: true }).click();
|
||||
await expect(dialog.getByRole('status')).toContainText('Создано: 1. Пропущено существующих: 1.');
|
||||
await expect(dialog.getByRole('status')).toContainText('alice');
|
||||
await page.keyboard.press('Escape');
|
||||
if (width < 768) {
|
||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
|
||||
} else await page.evaluate(() => {
|
||||
history.pushState({}, '', '/admin/sources');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
await expect(page.getByRole('article')).toContainText('Локальные обложки: 87 из 120 файлов');
|
||||
await expect(page.getByRole('article')).toContainText('Альбомов ожидают обработки: 3');
|
||||
await page.screenshot({ path: info.outputPath('source-covers.png') });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('moderator cannot see user transfer controls', async ({ page }) => {
|
||||
await login(page, 'moderator');
|
||||
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Экспорт пользователей', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Импорт пользователей', exact: true })).toHaveCount(0);
|
||||
});
|
||||
+257
-4
@@ -1,4 +1,4 @@
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { AuthSession, refreshListening } from '@pipu/core';
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
@@ -14,6 +14,256 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('social', () => {
|
||||
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.activityVisibility.set(bob.id, 'friends');
|
||||
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
|
||||
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(dialog).not.toBeInTheDocument());
|
||||
if (!automatic) {
|
||||
expect(api.listeningMembers.has(alice.id)).toBe(false);
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
expect(api.listeningRequests.size).toBe(1);
|
||||
const request = [...api.listeningRequests.values()][0]!;
|
||||
const room = [...api.listeningRooms.values()][0]!;
|
||||
api.listeningMembers.set(alice.id, room.id);
|
||||
api.listeningRequests.delete(request.id);
|
||||
refreshListening(session);
|
||||
}
|
||||
await screen.findByText('Слушаете вместе с bob');
|
||||
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
|
||||
await router.navigate({ to: '/friends' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(dialog).not.toBeInTheDocument());
|
||||
expect(api.listeningMembers.has(bob.id)).toBe(false);
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
|
||||
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains listening restrictions instead of reporting a chat error and can close after rejection', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.activityVisibility.set(bob.id, 'friends');
|
||||
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
|
||||
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
|
||||
expect(api.listeningRequests.size).toBe(0);
|
||||
expect(api.listeningRooms.size).toBe(0);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
|
||||
expect(dialog).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables listening together only while a friend is online, independently of music privacy', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
const conversation = await screen.findByRole('region', { name: 'Написать' });
|
||||
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
|
||||
api.presence.set(bob.id, Date.now());
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await within(conversation).findByText('В сети');
|
||||
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeEnabled();
|
||||
expect(api.activity.has(bob.id)).toBe(false);
|
||||
await user.click(within(conversation).getByRole('button', { name: 'Слушать вместе' }));
|
||||
await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
api.presence.set(bob.id, Date.now() - 46_000);
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Слушать вместе' })).not.toBeInTheDocument());
|
||||
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
|
||||
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('finds music beyond the last text page without acknowledging unread messages', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
for (let seq = 1; seq <= 61; seq++) api.messages.push({ id: crypto.randomUUID(), seq, sender_id: bob.id, recipient: alice.id, text: seq === 1 ? albumTracks[0]!.title : `Сообщение ${seq}`, attachment_kind: seq === 1 ? 'track' : 'none', ...(seq === 1 ? { attachment_id: albumTracks[0]!.id } : {}), created_at: new Date().toISOString(), read: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Музыка из переписки' }));
|
||||
await screen.findByRole('link', { name: 'Открыть трек' });
|
||||
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
|
||||
const firstCall = api.calls.length;
|
||||
api.messages.push({ id: crypto.randomUUID(), seq: 62, sender_id: bob.id, recipient: alice.id, text: 'Новая музыка', attachment_kind: 'track', attachment_id: albumTracks[0]!.id, created_at: new Date().toISOString(), read: false });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await screen.findByText('Новая музыка');
|
||||
expect(api.messages.at(-1)?.read).toBe(false);
|
||||
expect(api.calls.slice(firstCall).some(c => c.path.endsWith('/actions') && (c.body as { action?: string })?.action === 'read_chat')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the current conversation when another friend sends newer messages and confirms blocking', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const carol = makeUser('carol', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob, carol], '/friends', { signedInAs: alice });
|
||||
for (const friend of [bob, carol]) api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await screen.findByRole('heading', { name: 'bob' });
|
||||
await user.type(screen.getByRole('textbox', { name: 'Сообщение' }), 'Черновик для bob');
|
||||
api.messages.push({ id: crypto.randomUUID(), seq: 1, sender_id: carol.id, recipient: alice.id, text: 'Новое сообщение от carol', attachment_kind: 'none', created_at: new Date().toISOString(), read: false });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await screen.findByText('Новое сообщение от carol');
|
||||
expect(screen.getByRole('heading', { name: 'bob' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Сообщение' })).toHaveValue('Черновик для bob');
|
||||
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
|
||||
const confirm = await screen.findByRole('dialog', { name: 'Заблокировать bob?' });
|
||||
expect(api.friendships.size).toBe(2);
|
||||
await user.click(within(confirm).getByRole('button', { name: 'Отмена' }));
|
||||
expect(api.friendships.size).toBe(2);
|
||||
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
|
||||
await user.click(within(await screen.findByRole('dialog', { name: 'Заблокировать bob?' })).getByRole('button', { name: 'Заблокировать' }));
|
||||
await waitFor(() => expect(api.friendships.size).toBe(1));
|
||||
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows a room with locked playback controls, independent volume and local exit', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
const id = crypto.randomUUID();
|
||||
api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] });
|
||||
api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id);
|
||||
refreshListening(session);
|
||||
await screen.findByText('Слушаете вместе с bob');
|
||||
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled());
|
||||
expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled();
|
||||
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
|
||||
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
|
||||
expect(api.listeningMembers.has(alice.id)).toBe(false);
|
||||
});
|
||||
it('edits, replies, reacts, searches, deletes and preserves a draft between sections', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: /bob Начать разговор/ }));
|
||||
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
|
||||
await user.type(composer(), 'Первый вариант');
|
||||
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||
await screen.findByText('Первый вариант');
|
||||
const actions = async (name: string) => {
|
||||
await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!);
|
||||
await user.click(await screen.findByRole('menuitem', { name }));
|
||||
};
|
||||
await actions('Редактировать');
|
||||
await user.clear(composer()); await user.type(composer(), 'Исправленный текст');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByText('Исправленный текст', { exact: true });
|
||||
await actions('❤️');
|
||||
await screen.findByRole('button', { name: '❤️ 1' });
|
||||
await actions('Ответить');
|
||||
await user.type(composer(), 'Ответ на музыку');
|
||||
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(api.messages).toHaveLength(2));
|
||||
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
|
||||
await user.click(screen.getByRole('button', { name: 'Поиск в переписке' }));
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
|
||||
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
|
||||
await actions('Удалить для всех');
|
||||
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
|
||||
await user.type(composer(), 'Мой черновик');
|
||||
await user.click(screen.getByRole('button', { name: 'Друзья' }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Написать' }));
|
||||
expect(composer()).toHaveValue('Мой черновик');
|
||||
});
|
||||
|
||||
it('saves privacy and independent request admission preferences', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { api, user } = await renderApp([alice], '/', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Моя музыка скрыта' }));
|
||||
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
|
||||
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
|
||||
await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends'));
|
||||
await user.click(screen.getByText('Слушать вместе', { exact: true }));
|
||||
const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' });
|
||||
const auto = screen.getByRole('group', { name: 'Автоматически принимать от' });
|
||||
await user.click(within(requests).getByRole('button', { name: 'Друзьям' }));
|
||||
await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends'));
|
||||
await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled());
|
||||
await user.click(within(auto).getByRole('button', { name: 'Всем' }));
|
||||
await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' }));
|
||||
});
|
||||
it('shows persistent notifications and acknowledges only the chosen event', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
|
||||
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Уведомления 1' }));
|
||||
await screen.findByText('Хочет добавить вас в друзья');
|
||||
await user.click(screen.getByRole('button', { name: 'Прочитано' }));
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
|
||||
expect(api.notifications[0]?.read).toBe(true);
|
||||
await user.click(screen.getByRole('button', { name: 'Открыть: bob' }));
|
||||
await screen.findByRole('button', { name: 'Принять' });
|
||||
expect(router.state.location.pathname).toBe('/friends');
|
||||
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
|
||||
});
|
||||
it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const bob = makeUser('bob', 'user', { display_name: 'Борис' });
|
||||
const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
await user.click((await screen.findAllByRole('button', { name: 'Найти друзей' }))[0]!);
|
||||
await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob');
|
||||
await user.click(await screen.findByRole('button', { name: 'В друзья' }));
|
||||
await screen.findByRole('button', { name: 'Отменить заявку' });
|
||||
await user.click(screen.getByRole('button', { name: 'Отменить заявку' }));
|
||||
await screen.findByRole('button', { name: 'В друзья' });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Принять' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Написать' }));
|
||||
await user.type(await screen.findByLabelText('Сообщение', { exact: true }), 'Привет, послушай это');
|
||||
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||
expect(await screen.findByText('Привет, послушай это')).toBeInTheDocument();
|
||||
expect(api.messages).toHaveLength(1);
|
||||
await router.navigate({ to: '/track/$id', params: { id: albumTracks[0]!.id } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
|
||||
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await user.click(within(player).getByRole('button', { name: 'Поделиться треком' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Борис · Отправить' }));
|
||||
await screen.findByRole('button', { name: 'Борис · Отправлено' });
|
||||
expect(api.messages.at(-1)).toMatchObject({ attachment_kind: 'track', attachment_id: albumTracks[0]!.id, recipient: bob.id });
|
||||
expect(screen.getByRole('button', { name: 'Борис · Отправлено' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
|
||||
const api = fakeApi(users);
|
||||
const i18n = await createI18n('ru');
|
||||
@@ -27,7 +277,7 @@ async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: Fak
|
||||
<App session={session} router={router} queryClient={queryClient} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
return { api, router, session, i18n, user: userEvent.setup() };
|
||||
return { api, router, session, i18n, queryClient, user: userEvent.setup() };
|
||||
}
|
||||
|
||||
describe('sign-in', () => {
|
||||
@@ -294,7 +544,8 @@ describe('player', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Слушать' }));
|
||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
|
||||
await screen.findByRole('button', { name: 'Ссылка скопирована' });
|
||||
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
|
||||
});
|
||||
|
||||
@@ -517,7 +768,9 @@ describe('playlist sharing', () => {
|
||||
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
|
||||
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
|
||||
await screen.findByRole('button', { name: 'Ссылка скопирована' });
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Закрыть' }));
|
||||
const shareId = api.playlists.get(playlist.id)!.share_id!;
|
||||
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
|
||||
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
|
||||
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
||||
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
||||
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
||||
@@ -24,7 +26,7 @@ import { ConnectPage } from '../pages/ConnectPage';
|
||||
import { TrackPage } from '../pages/TrackPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { ActivityPlaylistPage, PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
@@ -33,6 +35,8 @@ import { SettingsPage } from '../pages/SettingsPage';
|
||||
import { SetupPage } from '../pages/SetupPage';
|
||||
import { AppShell } from '../widgets/AppShell';
|
||||
import { RouteError } from '../widgets/RouteError';
|
||||
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
|
||||
import { SocialPage } from '../features/social/Social';
|
||||
|
||||
export interface RouterContext {
|
||||
session: AuthSession;
|
||||
@@ -144,9 +148,14 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
};
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
|
||||
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string; tab?: 'messages' | 'people' | 'requests' } => ({ ...(typeof s.peer === 'string' ? { peer: s.peer } : {}), ...(s.tab === 'messages' || s.tab === 'people' || s.tab === 'requests' ? { tab: s.tab } : {}) }), component: () => { const search = socialRoute.useSearch(); return <SocialPage peer={search.peer} initialTab={search.tab} />; } });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||
const downloadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/downloads', component: DesktopDownloadsPage,
|
||||
beforeLoad: ({ context }) => { if (!context.desktop) throw redirect({ to: '/settings' }); },
|
||||
});
|
||||
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||
|
||||
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||
@@ -270,6 +279,14 @@ const adminSettingsRoute = createRoute({
|
||||
component: AdminSettingsPage,
|
||||
});
|
||||
|
||||
const serverUpdatesRoute = createRoute({
|
||||
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
|
||||
},
|
||||
});
|
||||
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
downloadAppRoute,
|
||||
androidAppRoute,
|
||||
@@ -279,8 +296,11 @@ const routeTree = rootRoute.addChildren([
|
||||
setupPasswordRoute,
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
activityPlaylistRoute,
|
||||
socialRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
downloadsRoute,
|
||||
sharedPlaylistRoute,
|
||||
playlistRoute,
|
||||
settingsRoute,
|
||||
@@ -299,6 +319,8 @@ const routeTree = rootRoute.addChildren([
|
||||
myUploadsRoute,
|
||||
uploadQueueRoute,
|
||||
adminSettingsRoute,
|
||||
serverUpdatesRoute,
|
||||
serverConfigRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthSession, type AuthUser } from '@pipu/core';
|
||||
|
||||
import { createDesktop, isDesktop } from './desktop';
|
||||
import { createDesktop, desktopFetch, isDesktop } from './desktop';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('desktop bridge', () => {
|
||||
it('restores the last desktop user when the proxy cannot reach the server', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
|
||||
const user = { id: 'alice', username: 'alice' } as AuthUser;
|
||||
const clear = vi.fn();
|
||||
const session = new AuthSession({ clientType: 'desktop', fetch: desktopFetch, baseUrl: 'http://local', refreshStore: {
|
||||
get: async () => 'refresh', set: vi.fn(), clear, getUser: async () => user,
|
||||
} });
|
||||
expect(await session.restore()).toEqual({ status: 'signed_in', offline: true, user });
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
it('reports a failed proxy hop as a network failure, preserving real HTTP errors', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
|
||||
await expect(desktopFetch('http://local/api')).rejects.toThrow('Server unavailable');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('denied', { status: 401 })));
|
||||
expect((await desktopFetch('http://local/api')).status).toBe(401);
|
||||
});
|
||||
it('is inactive in the browser', () => {
|
||||
expect(isDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -14,11 +14,19 @@ export function isDesktop(): boolean {
|
||||
return api() !== undefined;
|
||||
}
|
||||
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
/** A failed loopback hop must behave like a network failure to offline auth restore. */
|
||||
export const desktopFetch: typeof fetch = async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
if (response.headers.get('x-pipu-transport-error') === '1') throw new TypeError('Server unavailable');
|
||||
return response;
|
||||
};
|
||||
|
||||
export function desktopInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const tauri = api();
|
||||
if (!tauri) throw new Error('Tauri API unavailable');
|
||||
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||
}
|
||||
const invoke = desktopInvoke;
|
||||
|
||||
export type DesktopHandle = {
|
||||
proxyBase: string;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
|
||||
import { AddToPlaylist } from './playlists';
|
||||
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
@@ -70,15 +71,19 @@ export function Cover({
|
||||
}
|
||||
|
||||
/** Исполнители через запятую, каждый — ссылка. */
|
||||
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
||||
export function ArtistLinks({ artists, hideUrlPreview = false }: { artists: ArtistRef[]; hideUrlPreview?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
{artists.map((a, i) => (
|
||||
<Fragment key={a.id}>
|
||||
{i > 0 && ', '}
|
||||
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
||||
{hideUrlPreview ? <button type="button" role="link" className={s.link}
|
||||
onClick={() => void navigate({ to: '/artist/$id', params: { id: a.id } })}>
|
||||
{a.name}
|
||||
</Link>
|
||||
</button> : <Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
||||
{a.name}
|
||||
</Link>}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
@@ -218,6 +223,7 @@ export function TrackList({
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||
{!actions && <SaveOfflineButton track={tr} compact />}
|
||||
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||
{disliked ? (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { releaseNotesText, type Track } from '@pipu/core';
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthState, useSession } from '../../app/session';
|
||||
import { desktopInvoke, useDesktop } from '../../desktop';
|
||||
import { TrackList } from '../../entities/catalog';
|
||||
import { usePlayer, usePlayerState } from '../../player/context';
|
||||
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||
import { formatBytes } from '../../shared/lib/format';
|
||||
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
|
||||
import s from '../../pages/page.module.css';
|
||||
|
||||
export function useOffline() {
|
||||
const offline = usePlayer().offline;
|
||||
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
|
||||
return offline;
|
||||
}
|
||||
|
||||
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
|
||||
const offline = useOffline();
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
if (!offline) return null;
|
||||
return <span>
|
||||
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
|
||||
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
|
||||
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
|
||||
setBusy(true); setError(false);
|
||||
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
|
||||
.catch(() => setError(true)).finally(() => setBusy(false));
|
||||
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
|
||||
{error && <span role="alert">{t('desktop.download_failed')}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function DesktopOfflineSection() {
|
||||
const offline = useOffline();
|
||||
const { track } = usePlayerState();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [error, setError] = useState(false);
|
||||
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
|
||||
if (!offline) return null;
|
||||
const tracks = offline.tracks();
|
||||
return <section className={s.section}>
|
||||
<h2>{t('desktop.offline')}</h2>
|
||||
<p className={s.note}>{t('desktop.cache_hint')}</p>
|
||||
<div className={s.toolbar}>
|
||||
<span>{formatBytes(offline.bytes, i18n.language)}</span>
|
||||
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
|
||||
{track && <SaveOfflineButton track={track} />}
|
||||
</div>
|
||||
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
|
||||
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function DesktopDownloadsPage() {
|
||||
const { t } = useTranslation();
|
||||
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
|
||||
}
|
||||
|
||||
export function DesktopOfflineBanner() {
|
||||
const state = useAuthState();
|
||||
const desktop = useDesktop();
|
||||
const { t } = useTranslation();
|
||||
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
|
||||
}
|
||||
|
||||
type Release = { build: { version: string; size: number }; notes: string };
|
||||
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
|
||||
let update: Update = { status: 'idle' };
|
||||
let generation = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
|
||||
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
|
||||
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
|
||||
async function checkUpdate(server: string, manual = false) {
|
||||
if (update.status === 'checking' || update.status === 'installing') return;
|
||||
const key = `pipu.update.checked.${server}`;
|
||||
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
|
||||
setUpdate({ status: 'checking' });
|
||||
const requestGeneration = generation;
|
||||
try {
|
||||
const release = await desktopInvoke<Release | null>('update_check');
|
||||
if (requestGeneration !== generation) return;
|
||||
localStorage.setItem(key, String(Date.now()));
|
||||
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
|
||||
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
|
||||
}
|
||||
|
||||
export function DesktopUpdateButton() {
|
||||
const desktop = useDesktop();
|
||||
const state = useUpdate();
|
||||
const { t } = useTranslation();
|
||||
if (!desktop?.server) return null;
|
||||
return <section className={s.section}>
|
||||
<h2>{t('desktop.updates')}</h2>
|
||||
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
|
||||
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
|
||||
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function DesktopUpdateDialog() {
|
||||
const desktop = useDesktop();
|
||||
const session = useSession();
|
||||
const state = useUpdate();
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
if (!desktop?.server) return;
|
||||
const check = () => void checkUpdate(desktop.server!);
|
||||
check();
|
||||
window.addEventListener('focus', check);
|
||||
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
|
||||
}, [desktop, session]);
|
||||
useEffect(() => {
|
||||
if (!desktop) return;
|
||||
let stop: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
// The native updater emits progress through the same Tauri event surface as media controls.
|
||||
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
|
||||
void api?.event.listen('update-progress', (e) => {
|
||||
const progress = Number(e.payload);
|
||||
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
|
||||
}).then((fn) => { if (disposed) fn(); else stop = fn; });
|
||||
return () => { disposed = true; stop?.(); };
|
||||
}, [desktop]);
|
||||
if (!desktop || !state.release || state.dismissed) return null;
|
||||
const release = state.release;
|
||||
const busy = state.status === 'installing';
|
||||
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
|
||||
const notes = releaseNotesText(release.notes);
|
||||
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
|
||||
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
|
||||
<Button busy={busy} onClick={() => {
|
||||
setUpdate({ status: 'installing', release, progress: 0 });
|
||||
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
|
||||
}}>{t('mobile.update_install')}</Button>
|
||||
</>}>
|
||||
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
|
||||
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
|
||||
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
|
||||
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
|
||||
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthSession, PlayEvent } from '@pipu/core';
|
||||
import { desktopEventSink, evictRecent, offlineOwner } from './offline';
|
||||
import { createDesktop } from '../../desktop';
|
||||
import { albumTracks } from '../../test/fakeApi';
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
|
||||
describe('desktop cache policy', () => {
|
||||
it('evicts oldest recent files while keeping manual downloads outside the limit', () => {
|
||||
const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob });
|
||||
expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists offline events across sink restarts and deduplicates retries', async () => {
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } };
|
||||
await createDesktop();
|
||||
const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') });
|
||||
const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession;
|
||||
const owner = offlineOwner(session)!;
|
||||
const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' };
|
||||
await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline');
|
||||
await desktopEventSink(session, owner)([event]);
|
||||
expect(post.mock.calls[1]?.[1].body.events).toEqual([event]);
|
||||
expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core';
|
||||
import { desktopHandle } from '../../desktop';
|
||||
|
||||
export const RECENT_LIMIT = 500 * 1024 * 1024;
|
||||
type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number };
|
||||
const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0);
|
||||
|
||||
export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] {
|
||||
let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0);
|
||||
const removed: string[] = [];
|
||||
for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) {
|
||||
if (total <= limit) break;
|
||||
removed.push(e.id);
|
||||
total -= size(e);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function offlineOwner(session: AuthSession): string | null {
|
||||
const desktop = desktopHandle();
|
||||
return desktop?.server && session.state.status === 'signed_in'
|
||||
? JSON.stringify([desktop.server, session.state.user.id]) : null;
|
||||
}
|
||||
|
||||
/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */
|
||||
export class DesktopOffline {
|
||||
private db: Promise<IDBDatabase> | null = null;
|
||||
private loaded: Promise<void> | null = null;
|
||||
private entries: Entry[] = [];
|
||||
private urls = new Map<string, Track>();
|
||||
private objects = new Map<string, string[]>();
|
||||
private inUse = new Set<string>();
|
||||
private listeners = new Set<() => void>();
|
||||
private revision = 0;
|
||||
private writes: Promise<unknown> = Promise.resolve();
|
||||
private disposed = false;
|
||||
get ready(): Promise<void> {
|
||||
this.loaded ??= this.reload();
|
||||
return this.loaded;
|
||||
}
|
||||
activate() { this.disposed = false; }
|
||||
constructor(readonly owner: string, private session: AuthSession) {}
|
||||
private database(): Promise<IDBDatabase> {
|
||||
return this.db ??= new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(`pipu.offline.${this.owner}`, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
|
||||
snapshot = () => this.revision;
|
||||
private async reload() {
|
||||
const db = await this.database();
|
||||
const entries = await new Promise<Entry[]>((resolve, reject) => {
|
||||
const req = db.transaction('tracks').objectStore('tracks').getAll();
|
||||
req.onsuccess = () => resolve(req.result as Entry[]);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
if (this.disposed) return;
|
||||
this.entries = entries;
|
||||
for (const e of entries) {
|
||||
if (this.urls.has(e.id)) continue;
|
||||
const audio = URL.createObjectURL(e.audio);
|
||||
const cover = e.cover ? URL.createObjectURL(e.cover) : null;
|
||||
this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]);
|
||||
this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } });
|
||||
}
|
||||
for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) {
|
||||
this.urls.delete(id);
|
||||
if (!this.inUse.has(id)) this.release(id);
|
||||
}
|
||||
this.revision++;
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
tracks() { return this.entries.map((e) => this.local(e.track)); }
|
||||
private release(id: string) {
|
||||
for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url);
|
||||
this.objects.delete(id);
|
||||
}
|
||||
/** Keep only files currently playing/crossfading alive after cache deletion. */
|
||||
keep(ids: string[]) {
|
||||
this.inUse = new Set(ids);
|
||||
for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id);
|
||||
}
|
||||
local(track: Track): Track { return this.urls.get(track.id) ?? track; }
|
||||
has(id: string) { return this.entries.some((e) => e.id === id); }
|
||||
pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); }
|
||||
get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); }
|
||||
private mutate(fn: (store: IDBObjectStore) => void): Promise<void> {
|
||||
const work = this.writes.catch(() => undefined).then(async () => {
|
||||
const db = await this.database();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction('tracks', 'readwrite');
|
||||
fn(tx.objectStore('tracks'));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
await this.reload();
|
||||
});
|
||||
this.writes = work;
|
||||
return work;
|
||||
}
|
||||
private downloads = new Map<string, Promise<void>>();
|
||||
private downloadTail: Promise<unknown> = Promise.resolve();
|
||||
async save(track: Track, pinned = false): Promise<void> {
|
||||
await this.ready;
|
||||
const pending = this.downloads.get(track.id);
|
||||
if (pending) { await pending; return this.save(track, pinned); }
|
||||
const existing = this.entries.find((e) => e.id === track.id);
|
||||
if (existing) {
|
||||
await this.mutate((store) => {
|
||||
const latest = this.entries.find((e) => e.id === track.id);
|
||||
if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() });
|
||||
});
|
||||
return;
|
||||
}
|
||||
const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned));
|
||||
this.downloadTail = job;
|
||||
this.downloads.set(track.id, job);
|
||||
try { await job; } finally { this.downloads.delete(track.id); }
|
||||
}
|
||||
private async download(track: Track, pinned: boolean) {
|
||||
if (this.disposed) return;
|
||||
const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0];
|
||||
if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable');
|
||||
const response = await fetch(fresh.stream_url);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
const audio = await response.blob();
|
||||
if (!audio.size) throw new Error('Empty download');
|
||||
let cover: Blob | undefined;
|
||||
if (fresh.album.cover_url) {
|
||||
try {
|
||||
const response = await fetch(`${fresh.album.cover_url}&size=300`);
|
||||
if (response.ok) cover = await response.blob();
|
||||
} catch { /* Artwork is optional. */ }
|
||||
}
|
||||
if (this.disposed) return;
|
||||
const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() };
|
||||
await this.mutate((store) => {
|
||||
const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]);
|
||||
for (const id of removed) store.delete(id);
|
||||
if (!removed.includes(entry.id)) store.put(entry);
|
||||
});
|
||||
}
|
||||
async remove(id: string) { await this.mutate((store) => store.delete(id)); }
|
||||
async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); }
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
queueMicrotask(() => {
|
||||
if (!this.disposed) return;
|
||||
for (const id of this.objects.keys()) this.release(id);
|
||||
void this.downloadTail.catch(() => undefined).then(() => this.writes)
|
||||
.finally(() => this.db?.then((db) => db.close())).catch(() => undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist before sending; retries keep event IDs for server-side deduplication. */
|
||||
export function desktopEventSink(session: AuthSession, owner: string) {
|
||||
const key = `pipu.events.${owner}`;
|
||||
let sending = Promise.resolve();
|
||||
return async (events: PlayEvent[]) => {
|
||||
const work = sending.catch(() => undefined).then(async () => {
|
||||
const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[];
|
||||
const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()];
|
||||
localStorage.setItem(key, JSON.stringify(merged));
|
||||
if (offlineOwner(session) !== owner) return;
|
||||
while (merged.length) {
|
||||
if (offlineOwner(session) !== owner) return;
|
||||
const batch = merged.slice(0, 100);
|
||||
unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } }));
|
||||
merged.splice(0, batch.length);
|
||||
localStorage.setItem(key, JSON.stringify(merged));
|
||||
}
|
||||
});
|
||||
sending = work;
|
||||
await work;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, PageSkeleton } from '../../shared/ui/ui';
|
||||
import { JobPanel } from './JobPanel';
|
||||
import s from './management.module.css';
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation('admin');
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState<Record<string, string | null>>({});
|
||||
const [baseRevision, setBaseRevision] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [group, setGroup] = useState('all');
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const dirty = Object.keys(draft).length > 0;
|
||||
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
|
||||
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
|
||||
const running = status.data?.job?.state === 'running';
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
|
||||
window.addEventListener('beforeunload', prevent);
|
||||
return () => window.removeEventListener('beforeunload', prevent);
|
||||
}, [dirty]);
|
||||
useEffect(() => {
|
||||
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
|
||||
}, [status.data?.job?.id, status.data?.job?.state, qc]);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
|
||||
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
|
||||
onSuccess: (job) => {
|
||||
setConfirm(false);
|
||||
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
|
||||
void qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||
},
|
||||
});
|
||||
const change = (key: string, value: string | null) => {
|
||||
const entry = query.data!.entries.find((v) => v.key === key)!;
|
||||
const unchanged = value === null ? entry.local === null
|
||||
: entry.secret ? value === '' : value === (entry.local ?? '');
|
||||
const next = { ...draft };
|
||||
if (unchanged) delete next[key]; else next[key] = value;
|
||||
setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null);
|
||||
setDraft(next); setSaved(false); save.reset();
|
||||
};
|
||||
if (query.isPending) return <PageSkeleton />;
|
||||
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
|
||||
return <div className={s.page}>
|
||||
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
|
||||
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||
{query.data && <>
|
||||
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
|
||||
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
|
||||
<p className={s.note}>{t('management.config_files_hint')}</p>
|
||||
<div className={s.configList}>{entries.map((entry) => {
|
||||
const changed = Object.hasOwn(draft, entry.key);
|
||||
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
|
||||
const display = (stored: string | null) => entry.secret && stored ? '••••••••' : stored;
|
||||
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
|
||||
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
|
||||
<div className={s.values}><span>{t('management.effective')}: <code>{display(entry.effective) || '—'}</code></span><span>ENV: <code>{display(entry.env) ?? '—'}</code></span></div>
|
||||
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
|
||||
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} autoComplete={entry.secret ? 'new-password' : 'off'} value={value} disabled={running || save.isPending} placeholder={entry.secret ? entry.local ? t('management.secret_saved') : '' : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
|
||||
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
|
||||
</div>}
|
||||
</section>;
|
||||
})}</div>
|
||||
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
|
||||
{saved && !dirty && <p className={s.note} role="status">{t('management.saved')}</p>}
|
||||
{dirty && <section className={s.saveBar} aria-label={t('management.unsaved_panel')}>
|
||||
<div role="status">{t('management.unsaved', { count: Object.keys(draft).length })}</div>
|
||||
<div className={s.actions}><Button disabled={save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button></div>
|
||||
</section>}
|
||||
{!dirty && query.data.restart_required && !running && <section className={s.saveBar} aria-label={t('management.pending_restart')}>
|
||||
<div role="status">{t('management.pending_restart')}</div>
|
||||
<Button busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button>
|
||||
</section>}
|
||||
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
|
||||
<JobPanel job={status.data?.job} reconnecting={status.isError} />
|
||||
</>}
|
||||
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import s from './management.module.css';
|
||||
|
||||
export type Job = components['schemas']['ServerJob'];
|
||||
|
||||
export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) {
|
||||
const { t } = useTranslation('admin');
|
||||
const logRef = useRef<HTMLPreElement>(null);
|
||||
const followLog = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
followLog.current = true;
|
||||
}, [job?.id]);
|
||||
useLayoutEffect(() => {
|
||||
const log = logRef.current;
|
||||
if (log && followLog.current) log.scrollTop = log.scrollHeight;
|
||||
}, [job?.id, job?.log]);
|
||||
if (!job) return null;
|
||||
return <section className={s.card} aria-label={t('management.job')}>
|
||||
<div className={s.cardHead}>
|
||||
<div><span className={s.eyebrow}>{t('management.job')}</span><h2>{job.target}</h2></div>
|
||||
<span className={s.status} data-state={job.state} role="status">{t(`management.state.${job.state}`)}</span>
|
||||
</div>
|
||||
<p className={s.note}>{t('management.started')}: {new Date(job.started_at).toLocaleString()}
|
||||
{job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}</>}</p>
|
||||
{job.state === 'running' && <p className={s.note}>{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}</p>}
|
||||
<pre ref={logRef} className={s.log} tabIndex={0} aria-label={t('management.log')} onScroll={(event) => {
|
||||
const log = event.currentTarget;
|
||||
followLog.current = log.scrollHeight - log.clientHeight - log.scrollTop <= 4;
|
||||
}}>{job.log || t('management.waiting')}</pre>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
|
||||
import { JobPanel } from './JobPanel';
|
||||
import s from './management.module.css';
|
||||
|
||||
type Source = components['schemas']['ServerSource'];
|
||||
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
|
||||
|
||||
export function UpdatesPage() {
|
||||
const { t } = useTranslation('admin');
|
||||
const session = useSession();
|
||||
const qc = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [channel, setChannel] = useState<Channel>('latest');
|
||||
const [version, setVersion] = useState('');
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [editing, setEditing] = useState<Source[] | null>(null);
|
||||
const fresh = useRef(false);
|
||||
const queryKey = ['server-updates', sourceId, channel, version];
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const force = fresh.current;
|
||||
fresh.current = false;
|
||||
return unwrap(await session.client.GET('/api/v1/admin/server/updates', {
|
||||
params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } },
|
||||
}));
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000,
|
||||
retry: false,
|
||||
});
|
||||
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
|
||||
const running = query.data?.job?.state === 'running';
|
||||
const checking = query.isFetching;
|
||||
const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown';
|
||||
const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !running;
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||
const save = useMutation({
|
||||
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
|
||||
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
|
||||
});
|
||||
const start = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
|
||||
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined, expected_commit: query.data!.target_commit },
|
||||
})),
|
||||
onSuccess: (job) => {
|
||||
setConfirm(false);
|
||||
qc.setQueryData<components['schemas']['ServerUpdates']>(queryKey, (old) => old ? { ...old, job } : old);
|
||||
void refresh();
|
||||
},
|
||||
onError: () => { void refresh(); },
|
||||
});
|
||||
if (query.isPending) return <PageSkeleton />;
|
||||
return <div className={s.page}>
|
||||
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
|
||||
<Button onClick={() => { fresh.current = true; void query.refetch(); }} busy={query.isFetching}>{t('management.check_updates')}</Button>
|
||||
</header>
|
||||
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||
{query.data && <>
|
||||
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
|
||||
<section className={s.card}>
|
||||
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
|
||||
<label className={s.selectLabel}>{t('management.source')}
|
||||
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
|
||||
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className={s.url}>{source?.url}</p>
|
||||
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
|
||||
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
|
||||
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
|
||||
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
|
||||
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||
<div className={s.updateAction}>
|
||||
<div role="status" aria-live="polite">
|
||||
<strong>{t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)}</strong>
|
||||
{query.data.target_version && <p className={s.note}>{query.data.target_version} · {query.data.target_commit.slice(0, 12)}</p>}
|
||||
</div>
|
||||
{canInstall && <Button variant="primary" disabled={!source || !!query.data.source_error} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button>}
|
||||
</div>
|
||||
</section>
|
||||
<JobPanel job={query.data.job} reconnecting={query.isError} />
|
||||
</>}
|
||||
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
|
||||
<p>{t('management.confirm_update_text', { source: source?.name, target: query.data?.target_version || version })}</p>
|
||||
<p className={s.note}>{t('management.update_warning')}</p>
|
||||
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" disabled={!canInstall} busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
|
||||
</Dialog>}
|
||||
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
|
||||
<p className={s.note}>{t('management.sources_hint')}</p>
|
||||
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
|
||||
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
|
||||
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
|
||||
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
|
||||
</div>)}</div>
|
||||
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
|
||||
</Dialog>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
|
||||
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
|
||||
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
|
||||
.eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--text2); font-weight: 600; }
|
||||
.hero { display: flex; gap: 24px; align-items: center; justify-content: space-between; padding: 28px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(120deg, var(--accent-soft), var(--surface-a)); }
|
||||
.hero strong { display: block; font-family: var(--font-display); font-size: 30px; margin-top: 8px; overflow-wrap: anywhere; }
|
||||
.hero strong.priority { font-size: 19px; }
|
||||
.hero p { color: var(--text2); max-width: 340px; line-height: 1.6; font-size: 13px; }
|
||||
.card { padding: 24px; background: var(--surface-a); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: 18px; }
|
||||
.card h2 { margin: 0; font-size: 18px; }
|
||||
.selectLabel { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; }
|
||||
.selectLabel select { height: 42px; padding: 0 12px; font: inherit; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--surface2); max-width: 100%; }
|
||||
.selectLabel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.url { color: var(--text2); font: 12px monospace; overflow-wrap: anywhere; margin: -8px 0 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.status, .tag { padding: 5px 10px; border: 1px solid var(--line); border-radius: 99px; color: var(--text2); font-size: 12px; }
|
||||
.status[data-state='running'] { background: var(--accent-soft); color: var(--accent); }
|
||||
.status[data-state='succeeded'] { color: var(--ok); }
|
||||
.status[data-state='failed'] { color: var(--danger); }
|
||||
.log { margin: 0; padding: 18px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); max-height: 380px; min-height: 100px; overflow: auto; font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.sourceList { display: flex; flex-direction: column; gap: 16px; max-height: 50vh; overflow: auto; margin-bottom: 18px; }
|
||||
.sourceEditor { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.toolbar { display: grid; grid-template-columns: 1fr 220px; gap: 16px; align-items: end; }
|
||||
.configList { display: flex; flex-direction: column; gap: 12px; }
|
||||
.configRow { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-a); display: flex; flex-direction: column; gap: 14px; }
|
||||
.configRow[data-changed] { border-color: var(--accent); }
|
||||
.configMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.configMeta > code { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.values { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--text2); font-size: 12px; }
|
||||
.values code { overflow-wrap: anywhere; }
|
||||
.editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
|
||||
.saveBar { position: sticky; bottom: 12px; z-index: 2; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); box-shadow: 0 8px 32px #0003; font-size: 13px; }
|
||||
@media (max-width: 650px) { .hero { flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; } .hero p { max-width: none; } .toolbar { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; } .card, .configRow { padding: 16px; } .actions { justify-content: flex-start; } }
|
||||
|
||||
.updateAction { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 14px; }
|
||||
@@ -0,0 +1,132 @@
|
||||
import { CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDesktop } from '../../desktop';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { arrowBackIcon, arrowDownIcon, arrowUpIcon, chatIcon, closeIcon, moreIcon, musicIcon, searchIcon, smileIcon } from '../../shared/ui/icons';
|
||||
import { Button, EmptyState, Field, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
|
||||
import { ListenTogetherButton } from './Listening';
|
||||
import { ChatMusic, MessageText } from './ChatMusic';
|
||||
import { Presence } from './Presence';
|
||||
import s from './social.module.css';
|
||||
|
||||
export function ChatConversation({ person, api, queryKey, userId, active = true, onBack, actions }: {
|
||||
person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; active?: boolean; onBack?: () => void; actions?: ReactNode;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const server = useDesktop()?.server ?? window.location.origin;
|
||||
const draftKey = chatDraftKey(server, userId, person.id);
|
||||
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
|
||||
const [reply, setReply] = useState<DirectMessage | null>(null);
|
||||
const [editing, setEditing] = useState<DirectMessage | null>(null);
|
||||
const [view, setView] = useState<'conversation' | 'music'>('conversation');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [emojis, setEmojis] = useState(false);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const beforeEdit = useRef('');
|
||||
const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
|
||||
const typedAt = useRef(0);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const scroll = useRef<HTMLDivElement>(null);
|
||||
const previousLast = useRef<number | undefined>(undefined);
|
||||
const [visible, setVisible] = useState(document.visibilityState === 'visible');
|
||||
const music = view === 'music';
|
||||
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Storage may be unavailable in private mode. */ }
|
||||
}, [text, draftKey, editing]);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !music && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, active, visible, editing, music, person.id, api]);
|
||||
useEffect(() => () => { void api.typing(person.id, false).catch(() => undefined); }, [api, person.id]);
|
||||
useEffect(() => { if (composer.current) { composer.current.style.height = 'auto'; composer.current.style.height = `${Math.min(composer.current.scrollHeight, 144)}px`; } }, [text, music, active]);
|
||||
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible && !music, refetchInterval: active && visible && !music ? 2000 : false });
|
||||
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q, music], initialPageParam: undefined as number | undefined,
|
||||
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q, music), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined,
|
||||
enabled: active && visible, refetchInterval: active && visible ? 3000 : false });
|
||||
const list = [...(messages.data?.pages ?? [])].reverse().flat();
|
||||
const last = list.at(-1)?.seq;
|
||||
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
|
||||
useEffect(() => {
|
||||
const el = scroll.current;
|
||||
if (el && active && !q && !music && (atBottom || previousLast.current === undefined)) el.scrollTop = el.scrollHeight;
|
||||
previousLast.current = last;
|
||||
}, [last, active, q, music]);
|
||||
useEffect(() => {
|
||||
const el = scroll.current;
|
||||
const latestVisible = el && el.scrollHeight - el.scrollTop - el.clientHeight < 64;
|
||||
if (last && firstUnread && latestVisible && visible && active && !q && !music && atBottom) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
|
||||
}, [last, firstUnread, visible, active, q, music, atBottom, api, person.id, cache, messages.dataUpdatedAt]);
|
||||
const refresh = () => cache.invalidateQueries({ queryKey });
|
||||
const update = useMutation({ mutationFn: (input: { id: string; body: ChatMessageUpdate }) => api.updateMessage(person.id, input.id, input.body), onSuccess: refresh });
|
||||
const send = useMutation({ mutationFn: async () => {
|
||||
if (editing) { await api.updateMessage(person.id, editing.id, { action: 'edit', text }); return; }
|
||||
if (!pending.current || pending.current.text !== text || pending.current.reply_to !== reply?.id) pending.current = { id: uuidv7(), text, ...(reply ? { reply_to: reply.id } : {}) };
|
||||
await api.send(person.id, { ...pending.current, attachment_kind: 'none' });
|
||||
}, onSuccess: async () => { setText(editing ? beforeEdit.current : ''); setEditing(null); setReply(null); pending.current = null; setAtBottom(true); await refresh(); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; composer.current?.focus(); } });
|
||||
const cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
|
||||
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
|
||||
const changeView = (next: typeof view) => { setView(next); setSearch(''); setQ(''); setSearchOpen(false); previousLast.current = undefined; setAtBottom(true); };
|
||||
const older = async () => {
|
||||
const el = scroll.current;
|
||||
const height = el?.scrollHeight ?? 0;
|
||||
const top = el?.scrollTop ?? 0;
|
||||
await messages.fetchNextPage();
|
||||
requestAnimationFrame(() => { if (el) el.scrollTop = top + el.scrollHeight - height; });
|
||||
};
|
||||
return <section className={s.chat} aria-label={t('social.chat')}>
|
||||
<header className={s.chatHeader}>
|
||||
{onBack && <Button className={`${s.iconButton} ${s.backButton}`} variant="ghost" icon={arrowBackIcon} aria-label={t('social.back_to_messages')} onClick={onBack} />}
|
||||
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
|
||||
<div className={s.chatIdentity}><h2>{person.display_name}</h2><Presence online={person.online} /></div>
|
||||
<div className={s.chatTools}><ListenTogetherButton person={person} /><Button variant="ghost" className={s.iconButton} icon={searchIcon} aria-label={t('social.chat_search')} aria-expanded={searchOpen} onClick={() => { setSearchOpen(v => !v); setSearch(''); setQ(''); }} />{actions}</div>
|
||||
</header>
|
||||
{person.activity && <div className={s.nowListening}><Icon src={musicIcon} size={18} /><div><span>{t('social.listening')}</span><Link to="/track/$id" params={{ id: person.activity.track_id }}>{person.activity.track_title}{person.activity.artist ? ` · ${person.activity.artist}` : ''}</Link>{person.activity.context === 'playlist' && person.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: person.id }}>{t('social.from_playlist', { name: person.activity.playlist_name ?? '' })}</Link>}</div></div>}
|
||||
<nav className={s.chatTabs} aria-label={t('social.conversation_content')}><button type="button" aria-pressed={!music} onClick={() => changeView('conversation')}><Icon src={chatIcon} size={16} />{t('social.conversation')}</button><button type="button" aria-pressed={music} onClick={() => changeView('music')}><Icon src={musicIcon} size={16} />{t('social.shared_music')}</button></nav>
|
||||
{searchOpen && <div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} autoFocus /><Button variant="ghost" icon={closeIcon} className={s.iconButton} aria-label={t('social.close_search')} onClick={() => { setSearchOpen(false); setSearch(''); setQ(''); }} /></div>}
|
||||
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null}</FormError>
|
||||
<div ref={scroll} className={`${s.messages} ${music ? s.musicMessages : ''}`} onScroll={e => { const el = e.currentTarget; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 64); }}>
|
||||
{messages.hasNextPage && <Button className={s.older} busy={messages.isFetchingNextPage} onClick={() => void older()}>{t('social.older')}</Button>}
|
||||
{messages.isPending && <div className={s.chatSkeleton} role="status" aria-label={t('social.loading')}><Skeleton style={{ height: 70, width: '70%' }} /><Skeleton style={{ height: 46, width: '55%', alignSelf: 'end' }} /><Skeleton style={{ height: 80, width: '65%' }} /></div>}
|
||||
{!list.length && messages.isSuccess && <EmptyState icon={music ? musicIcon : chatIcon}>{t(q ? 'social.chat_no_results' : music ? 'social.no_shared_music' : 'social.first_message')}</EmptyState>}
|
||||
{list.map((m, i) => <Fragment key={m.id}>
|
||||
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
|
||||
{m.id === firstUnread && !q && !music && <div className={s.newMessages}>{t('social.new_messages')}</div>}
|
||||
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''} ${music ? s.mediaBubble : ''}`}>
|
||||
{m.reply_to && !music && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
|
||||
{(m.deleted || m.text && m.attachment_kind === 'none') && <div className={s.messageText}>{m.deleted ? t('social.message_deleted') : <MessageText text={m.text} />}</div>}
|
||||
{!m.deleted && m.attachment_id && m.attachment_kind !== 'none' && <ChatMusic message={m} scope={queryKey} />}
|
||||
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
|
||||
<div className={s.messageFoot}><small className={s.muted}>{new Date(m.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })}{m.edited_at && !m.deleted ? ` · ${t('social.edited')}` : ''}{m.sender_id === userId ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
|
||||
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger={<Icon src={moreIcon} size={18} />} items={[
|
||||
{ label: t('social.reply'), onSelect: () => { changeView('conversation'); if (editing) cancelEdit(); setReply(m); requestAnimationFrame(() => composer.current?.focus()); } },
|
||||
...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })),
|
||||
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { changeView('conversation'); beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); requestAnimationFrame(() => composer.current?.focus()); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
|
||||
]} />}
|
||||
</div>
|
||||
</article>
|
||||
</Fragment>)}
|
||||
{send.isPending && !!text.trim() && !editing && !music && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
|
||||
</div>
|
||||
{!atBottom && !music && <Button className={s.jumpToBottom} icon={arrowDownIcon} onClick={() => { if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; setAtBottom(true); }}>{t('social.latest_messages')}</Button>}
|
||||
{!music && <>
|
||||
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? <><span className={s.typingDots} aria-hidden><i /><i /><i /></span>{person.display_name} {t('social.typing')}</> : null}</div>
|
||||
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" icon={closeIcon} className={s.iconButton} onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')} /></div>}
|
||||
{emojis && <div className={s.emojiPicker}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" aria-label={emoji} onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); composer.current?.focus(); }}>{emoji}</button>)}</div>}
|
||||
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
|
||||
<Button variant="ghost" icon={smileIcon} className={s.iconButton} aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)} />
|
||||
<textarea ref={composer} aria-label={t('social.message')} placeholder={t('social.message_placeholder')} maxLength={4000} rows={1} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
|
||||
<Button type="submit" variant="primary" className={s.iconButton} icon={arrowUpIcon} aria-label={t(editing ? 'playlists.save' : 'social.send')} busy={send.isPending} disabled={!text.trim()} />
|
||||
</form>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { MessageText } from './ChatMusic';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('message text', () => {
|
||||
it('compacts a long URL while preserving its full destination when text is collapsed', async () => {
|
||||
const i18n = await createI18n('ru');
|
||||
const url = `https://example.com/music/${'long-path-'.repeat(90)}?share=abc`;
|
||||
render(<I18nextProvider i18n={i18n}><MessageText text={`Послушай ${url}`} /></I18nextProvider>);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', url);
|
||||
expect(screen.getByRole('link').textContent!.length).toBeLessThan(65);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole('button', { name: 'Показать полностью' }));
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', url);
|
||||
expect(screen.getByRole('button', { name: 'Свернуть текст' })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('keeps encoded text and markup literal, with expansion for long messages', async () => {
|
||||
const i18n = await createI18n('ru');
|
||||
const text = `<script>alert(1)</script> ${'%D0%BC%20'.repeat(100)}`;
|
||||
const { container } = render(<I18nextProvider i18n={i18n}><MessageText text={text} /></I18nextProvider>);
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
expect(container.textContent).toContain('%D0%BC%20');
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Показать полностью' }));
|
||||
expect(container.textContent).toContain(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { unwrap, type DirectMessage } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { useSession } from '../../app/session';
|
||||
import { Cover } from '../../entities/catalog';
|
||||
import { usePlayer, usePlayerState } from '../../player/context';
|
||||
import { formatDuration } from '../../shared/lib/format';
|
||||
import { musicIcon, pauseIcon, playIcon, queueIcon } from '../../shared/ui/icons';
|
||||
import { Button, Icon } from '../../shared/ui/ui';
|
||||
import s from './social.module.css';
|
||||
|
||||
/** Catalog reads stay in the client adapter; message text is never decoded into metadata. */
|
||||
export function ChatMusic({ message, scope }: { message: DirectMessage; scope: readonly unknown[] }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const state = usePlayerState();
|
||||
const id = message.attachment_id!;
|
||||
const track = useQuery({ queryKey: [...scope, 'attachment', 'track', id], staleTime: 60_000, retry: false,
|
||||
enabled: message.attachment_kind === 'track', queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })) });
|
||||
const playlist = useQuery({ queryKey: [...scope, 'attachment', 'playlist', id], retry: false,
|
||||
enabled: message.attachment_kind === 'playlist', queryFn: async () => unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } })) });
|
||||
const isTrack = message.attachment_kind === 'track';
|
||||
const query = isTrack ? track : playlist;
|
||||
const tr = !track.isError ? track.data : undefined;
|
||||
const pl = !playlist.isError ? playlist.data : undefined;
|
||||
const metadataTitle = isTrack ? tr?.title : pl?.name;
|
||||
const title = metadataTitle || message.text || t(isTrack ? 'social.shared_track' : 'social.shared_playlist');
|
||||
const album = isTrack ? tr?.album : pl?.tracks[0]?.album;
|
||||
const current = !!tr && tr.id === state.track?.id;
|
||||
const available = !query.isError && (isTrack ? tr?.available : pl?.tracks.some(x => x.available));
|
||||
const subtitle = query.isError ? t('social.attachment_unavailable') : isTrack ? tr ? `${tr.artists.map(a => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}` : t('social.shared_track') : t('social.shared_playlist');
|
||||
return <><div className={`${s.musicCard} ${query.isError ? s.musicUnavailable : ''}`}>
|
||||
{album ? <Cover album={album} size={48} /> : <span className={s.musicCover}><Icon src={isTrack ? musicIcon : queueIcon} size={24} /></span>}
|
||||
<div className={s.musicInfo}>
|
||||
<Link to={isTrack ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id, shareId: id }} aria-label={t(isTrack ? 'social.listen_track' : 'social.listen_playlist')} title={title}>{title}</Link>
|
||||
<span>{subtitle}</span>
|
||||
</div>
|
||||
<Button variant="ghost" className={s.iconButton} icon={current && state.playing ? pauseIcon : playIcon} aria-label={t(current && state.playing ? 'player.pause' : 'catalog.play')} disabled={!available || state.listening?.locked} onClick={() => {
|
||||
if (isTrack && tr) { if (current) player.toggle(); else player.playList([tr], 0, { type: 'queue' }); }
|
||||
else if (pl) player.playList(pl.tracks.filter(x => x.available), 0, { type: 'queue' });
|
||||
}} />
|
||||
</div>{message.text && message.text.trim() !== metadataTitle?.trim() && <span className={s.attachmentCaption}><MessageText text={message.text} /></span>}</>;
|
||||
}
|
||||
|
||||
/** Compact only actual HTTP(S) URLs. Arbitrary encoded text remains literal. */
|
||||
export function MessageText({ text }: { text: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const long = text.length > 600;
|
||||
const shown = long && !expanded ? `${text.slice(0, 400)}…` : text;
|
||||
const originalParts = text.split(/(https?:\/\/[^\s<>]+)/g);
|
||||
return <>{shown.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) => {
|
||||
if (!/^https?:\/\//.test(part)) return part;
|
||||
try {
|
||||
const original = originalParts[i] ?? part;
|
||||
const url = new URL(original);
|
||||
return <a key={i} href={url.href} target="_blank" rel="noopener noreferrer" title={original}>{url.hostname}{url.pathname === '/' ? '' : `${url.pathname.slice(0, 34)}${url.pathname.length > 34 || url.search ? '…' : ''}`}</a>;
|
||||
} catch { return part; }
|
||||
})}{long && <button type="button" className={s.expandText} aria-expanded={expanded} onClick={() => setExpanded(v => !v)}>{t(expanded ? 'social.show_less' : 'social.show_more')}</button>}</>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { socialApi, uuidv7, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
import { useDesktop } from '../../desktop';
|
||||
import { usePlayer, usePlayerState } from '../../player/context';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||
import { waveHeadphonesIcon } from '../../shared/ui/icons';
|
||||
import s from './social.module.css';
|
||||
|
||||
function useListening() {
|
||||
const session = useSession(); const user = useUser(); const server = useDesktop()?.server ?? '';
|
||||
const api = useMemo(() => socialApi(session), [session]);
|
||||
const key = ['listening', server, user.id] as const;
|
||||
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||
return { api, key, query, user };
|
||||
}
|
||||
export function ListeningPanel({ showPreferences = true, showRequests = true }: { showPreferences?: boolean; showRequests?: boolean }) {
|
||||
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||
const { api, key, query, user } = useListening();
|
||||
const prefs = query.data?.preferences;
|
||||
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: (input: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: uuidv7(), action: input.action, target_id: input.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <div className={s.stack}>
|
||||
{showPreferences && <details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
|
||||
{(['requests', 'auto_accept'] as const).map(field => <fieldset key={field} className={s.privacy} disabled={!prefs || save.isPending}><legend>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(prefs?.[field] ?? 'nobody') === v} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => prefs && save.mutate({ ...prefs, [field]: v })}>{t(`social.visibility_${v}`)}</Button>)}</div></fieldset>)}
|
||||
<label><input type="checkbox" checked={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onChange={e => prefs && save.mutate({ ...prefs, autoplay: e.target.checked })} /> {t('social.stream_autoplay')}</label>
|
||||
</details>}
|
||||
{showRequests && query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
|
||||
<FormError>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</FormError>
|
||||
</div>;
|
||||
}
|
||||
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
|
||||
return <><Button disabled={!person.online} title={!person.online ? t('social.together_offline') : undefined} variant="ghost" icon={waveHeadphonesIcon} aria-label={t('social.together')} onClick={() => setOpen(true)}><span className={s.togetherLabel}>{t('social.together')}</span></Button>{open && person.online && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
|
||||
}
|
||||
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||
const { api, key } = useListening(); const state = usePlayerState();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
|
||||
const [id] = useState(uuidv7);
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
|
||||
</fieldset><p className={s.muted}>{t('social.stream_hint')}</p>
|
||||
<FormError>{action.error ? errorText(action.error) : null}</FormError>
|
||||
{action.isSuccess ? <p role="status">{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</p> : <Button variant="primary" busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onClick={() => action.mutate()}>{t('social.send')}</Button>}
|
||||
</Dialog>;
|
||||
}
|
||||
export function ListeningBanner() {
|
||||
const { t } = useTranslation(); const state = usePlayerState(); const player = usePlayer();
|
||||
const session = useSession(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: uuidv7(), action: room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'end' : 'leave', target_id: room.id }) });
|
||||
if (!room) return null;
|
||||
return <section className={s.stream} aria-label={t('social.together')}><div><strong>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</strong><p className={s.muted}>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</p><details><summary>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</summary>{room.participants.map(p => <div key={p.id}>{p.name}</div>)}</details></div>
|
||||
<div className={s.actions}>{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button onClick={() => player.beginListeningAudio()}>{t('social.stream_start_audio')}</Button>}<Button busy={action.isPending} variant="ghost" onClick={() => action.mutate()}>{t(room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'social.stream_end' : 'social.stream_leave')}</Button></div>
|
||||
<FormError>{action.error ? errorText(action.error) : null}</FormError>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouterState, useNavigate } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, EmptyState, FormError, Icon } from '../../shared/ui/ui';
|
||||
import { chatIcon, closeIcon, groupIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
import { Presence } from './Presence';
|
||||
import { useSocialDialogOpen } from './Social';
|
||||
import { useSocial } from './queries';
|
||||
import s from './social.module.css';
|
||||
|
||||
/** Lives in the shell so browsing and minimizing never change the selected conversation. */
|
||||
export function MiniSocial() {
|
||||
const { t } = useTranslation();
|
||||
const { api, inbox, key, user } = useSocial();
|
||||
const errorText = useErrorText();
|
||||
const navigate = useNavigate();
|
||||
const pathname = useRouterState({ select: state => state.location.pathname });
|
||||
const dialogOpen = useSocialDialogOpen();
|
||||
const [desktop, setDesktop] = useState(() => window.matchMedia?.('(min-width: 1024px)').matches ?? false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [peer, setPeer] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const shown = desktop && pathname !== '/friends';
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia?.('(min-width: 1024px)');
|
||||
if (!media) return;
|
||||
const change = () => setDesktop(media.matches);
|
||||
media.addEventListener('change', change);
|
||||
return () => media.removeEventListener('change', change);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (open && shown) panel.current?.querySelector<HTMLInputElement>('input')?.focus();
|
||||
}, [open, shown]);
|
||||
const friends = (inbox.data?.people ?? []).filter(person => person.relationship === 'friend');
|
||||
const selected = friends.find(person => person.id === peer);
|
||||
const unread = friends.reduce((sum, person) => sum + person.unread, 0);
|
||||
const filtered = [...friends].sort((a, b) => Number(b.online) - Number(a.online) || (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name))
|
||||
.filter(person => `${person.display_name} ${person.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
|
||||
const minimize = () => { setOpen(false); trigger.current?.focus(); };
|
||||
if (!shown) return null;
|
||||
return <aside className={`${s.miniDock} ${user.ui_prefs.web_player === 'island' ? s.miniDockIsland : ''}`} aria-label={t('social.mini_chat')}>
|
||||
<section hidden={!open} ref={panel} id="mini-social-panel" className={s.miniPanel} aria-label={t('social.title')}
|
||||
onKeyDown={event => { if (event.key === 'Escape' && !event.defaultPrevented && !dialogOpen) { event.stopPropagation(); minimize(); } }}>
|
||||
<header className={s.miniHeader}>
|
||||
<strong>{t('social.title')}</strong>
|
||||
<Button variant="ghost" onClick={() => { setOpen(false); void navigate({ to: '/friends', search: selected ? { peer: selected.id, tab: 'messages' } : {} }); }}>{t('social.open_full')}</Button>
|
||||
<Button variant="ghost" icon={closeIcon} aria-label={t('social.minimize_chat')} onClick={minimize} />
|
||||
</header>
|
||||
<FormError>{inbox.error ? errorText(inbox.error) : null}</FormError>
|
||||
<div className={s.miniWorkspace}>
|
||||
<div className={s.miniFriends}>
|
||||
<label className={s.listSearch}><Icon src={searchIcon} size={16} /><input aria-label={t('social.find_conversation')} placeholder={t('social.find_conversation')} value={search} onChange={event => setSearch(event.target.value)} /></label>
|
||||
{inbox.isPending && <p className={s.muted}>{t('social.loading')}</p>}
|
||||
{inbox.isSuccess && !filtered.length && <p className={s.muted}>{t(search ? 'social.no_results' : 'social.no_friends_share')}</p>}
|
||||
{filtered.map(person => <button key={person.id} type="button" className={`${s.dialogRow} ${peer === person.id ? s.selected : ''}`} aria-pressed={peer === person.id} onClick={() => setPeer(person.id)}>
|
||||
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
|
||||
<span className={s.dialogBody}><strong>{person.display_name}</strong><Presence online={person.online} /></span>
|
||||
{person.unread > 0 && <span className={s.badge}>{person.unread}</span>}
|
||||
</button>)}
|
||||
</div>
|
||||
{selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={open && !dialogOpen} />
|
||||
: <div className={s.welcome}><EmptyState icon={groupIcon}>{t('social.choose_chat')}</EmptyState></div>}
|
||||
</div>
|
||||
</section>
|
||||
<button ref={trigger} type="button" className={s.miniTrigger} aria-label={t('social.mini_chat')} title={t('social.mini_chat')} aria-expanded={open} aria-controls="mini-social-panel" onClick={() => setOpen(value => !value)}>
|
||||
<Icon src={open ? closeIcon : chatIcon} size={22} />
|
||||
{unread > 0 && <span className={s.badge}>{unread > 99 ? '99+' : unread}</span>}
|
||||
</button>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { checkIcon, chevronRightIcon, inboxIcon } from '../../shared/ui/icons';
|
||||
import { Dialog, EmptyState, FormError, Icon, Skeleton } from '../../shared/ui/ui';
|
||||
import { useSocial } from './queries';
|
||||
import s from './notifications.module.css';
|
||||
|
||||
export function NotificationsDialog({ onClose, onOpen }: { onClose: () => void; onOpen: (id: string) => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { api, inbox, key } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const read = useMutation({ mutationFn: (id: string) => api.action({ action: 'read_notification', target_id: id }), onSuccess: () => cache.invalidateQueries({ queryKey: key }) });
|
||||
const events = inbox.data?.notifications ?? [];
|
||||
const unread = events.filter(event => !event.read).length;
|
||||
const shown = unreadOnly ? events.filter(event => !event.read) : events;
|
||||
return <Dialog title={t('social.notifications')} onClose={onClose} className={s.dialog}>
|
||||
<nav className={s.filters} aria-label={t('social.notifications')}>
|
||||
<button type="button" aria-pressed={!unreadOnly} onClick={() => setUnreadOnly(false)}>{t('social.notifications_all')}</button>
|
||||
<button type="button" aria-pressed={unreadOnly} onClick={() => setUnreadOnly(true)}>{t('social.notifications_unread')}{unread > 0 && <span className={s.count}>{unread}</span>}</button>
|
||||
</nav>
|
||||
<FormError>{read.error || inbox.error ? errorText(read.error ?? inbox.error) : null}</FormError>
|
||||
<div className={s.list} tabIndex={0} role="region" aria-label={t('social.notifications')}>
|
||||
{inbox.isPending && <div className={s.loading} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(value => <Skeleton key={value} style={{ height: 68 }} />)}</div>}
|
||||
{inbox.isSuccess && !shown.length && <div className={s.empty}><EmptyState icon={unreadOnly ? checkIcon : inboxIcon}>{t(unreadOnly ? 'social.notifications_caught_up' : 'social.no_notifications')}</EmptyState></div>}
|
||||
{shown.map(event => {
|
||||
const date = new Date(event.created_at);
|
||||
return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}>
|
||||
<button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => { if (!event.read) read.mutate(event.id); onOpen(event.actor_id); }}>
|
||||
<span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span>
|
||||
<span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span>
|
||||
<time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time>
|
||||
</span>
|
||||
<Icon src={chevronRightIcon} size={18} className={s.arrow} />
|
||||
</button>
|
||||
{!event.read && <button type="button" className={s.read} disabled={read.isPending} aria-label={t('social.mark_read')} title={t('social.mark_read')} onClick={() => read.mutate(event.id)}><span className={s.dot} /><Icon src={checkIcon} size={18} /></button>}
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import s from './social.module.css';
|
||||
|
||||
export function Presence({ online }: { online: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return <span className={`${s.presence} ${online ? s.presenceOnline : ''}`}><span className={s.presenceDot} aria-hidden />{t(online ? 'social.online' : 'social.offline')}</span>;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
|
||||
import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons';
|
||||
import s from './social.module.css';
|
||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
import { Presence } from './Presence';
|
||||
import { useSocial } from './queries';
|
||||
import { NotificationsDialog } from './NotificationsDialog';
|
||||
|
||||
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||
|
||||
export function FriendRecommendations() {
|
||||
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <div><label><input type="checkbox" checked={inbox.data?.friends_recommendations ?? true} disabled={!inbox.data || save.isPending} onChange={e => save.mutate(e.target.checked)} /> {t('social.friends_recommendations')}</label><p className={s.muted}>{t('social.friends_recommendations_hint')}</p><FormError>{save.error ? errorText(save.error) : null}</FormError></div>;
|
||||
}
|
||||
|
||||
type ShareValue = { attachment: MusicAttachment; url: string };
|
||||
type AccountDialog = 'privacy' | 'notifications' | null;
|
||||
const AccountDialogContext = createContext<{ dialog: AccountDialog; setDialog: (dialog: AccountDialog) => void }>({ dialog: null, setDialog: () => {} });
|
||||
export const useSocialDialogOpen = () => useContext(AccountDialogContext).dialog !== null;
|
||||
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
|
||||
export const useMusicShare = () => useContext(ShareContext);
|
||||
export function SocialProvider({ children }: { children: ReactNode }) {
|
||||
const [share, setShare] = useState<ShareValue | null>(null);
|
||||
const [dialog, setDialog] = useState<AccountDialog>(null);
|
||||
return <AccountDialogContext.Provider value={{ dialog, setDialog }}><ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider></AccountDialogContext.Provider>;
|
||||
}
|
||||
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { api, inbox, key } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [done, setDone] = useState<string[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const ids = useRef(new Map<string, string>());
|
||||
const action = useMutation({ mutationFn: async (peer: string) => {
|
||||
if (peer === 'copy') { await navigator.clipboard.writeText(value.url); setCopied(true); return; }
|
||||
if (peer === 'external') {
|
||||
try { await navigator.share({ title: value.attachment.title, url: value.url }); }
|
||||
catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) throw error; }
|
||||
return;
|
||||
}
|
||||
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
|
||||
await api.send(peer, { id, text: value.attachment.title, attachment_kind: value.attachment.kind, attachment_id: value.attachment.id });
|
||||
setDone(old => [...old, peer]);
|
||||
await cache.invalidateQueries({ queryKey: key });
|
||||
} });
|
||||
return <Dialog title={t('player.share')} onClose={close}>
|
||||
<div className={s.stack}><strong>{value.attachment.title}</strong>
|
||||
<Button busy={action.isPending && action.variables === 'copy'} onClick={() => action.mutate('copy')}>{t(copied ? 'social.copied' : 'social.copy')}</Button>
|
||||
{!!navigator.share && <Button busy={action.isPending && action.variables === 'external'} onClick={() => action.mutate('external')}>{t('social.external')}</Button>}
|
||||
<h3>{t('social.send_friend')}</h3>
|
||||
{inbox.isPending && <p>{t('social.loading')}</p>}
|
||||
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <p>{t('social.no_friends_share')}</p>}
|
||||
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} disabled={done.includes(p.id) || action.isPending} onClick={() => action.mutate(p.id)}>{p.display_name} · {t(done.includes(p.id) ? 'social.sent' : 'social.send')}</Button>)}
|
||||
<FormError>{action.error || inbox.error ? errorText(action.error ?? inbox.error) : null}</FormError>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export function useSocialProfileMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { api, inbox, key } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const errorText = useErrorText();
|
||||
const { dialog, setDialog } = useContext(AccountDialogContext);
|
||||
const closeDialog = useCallback(() => setDialog(null), [setDialog]);
|
||||
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const people = inbox.data?.people ?? [];
|
||||
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
|
||||
const open = (id: string) => {
|
||||
const relationship = people.find(p => p.id === id)?.relationship;
|
||||
const tab = relationship === 'friend' ? 'messages' : relationship === 'incoming' || relationship === 'outgoing' ? 'requests' : 'people';
|
||||
setDialog(null);
|
||||
void navigate({ to: '/friends', search: { peer: id, tab } });
|
||||
};
|
||||
return {
|
||||
items: [
|
||||
{ label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') },
|
||||
{ label: t('social.notifications'), badge: inbox.data?.unread, icon: inboxIcon, onSelect: () => setDialog('notifications') },
|
||||
],
|
||||
dialogs: <>
|
||||
{dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={closeDialog} className={s.preferencesDialog}>
|
||||
<fieldset className={s.privacy} disabled={!inbox.data} aria-busy={visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-disabled={visibility.isPending} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => { if (!visibility.isPending) visibility.mutate(v); }}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
|
||||
<FormError>{visibility.error || action.error || inbox.error ? errorText(visibility.error ?? action.error ?? inbox.error) : null}</FormError>
|
||||
<ListeningPanel showRequests={false} />
|
||||
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => action.mutate({ action: op, target_id: p.id })} />)}</section>}
|
||||
</Dialog>}
|
||||
{dialog === 'notifications' && <NotificationsDialog onClose={closeDialog} onOpen={open} />}
|
||||
</>,
|
||||
};
|
||||
}
|
||||
|
||||
type SocialTab = 'messages' | 'people' | 'requests';
|
||||
type ConfirmAction = { person: SocialPerson; action: 'remove' | 'block' };
|
||||
|
||||
export function SocialPage({ peer = '', initialTab }: { peer?: string; initialTab?: SocialTab }) {
|
||||
const { dialog } = useContext(AccountDialogContext);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { api, inbox, key, user } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const errorText = useErrorText();
|
||||
const [tab, setTab] = useState<SocialTab>('messages');
|
||||
const [search, setSearch] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
|
||||
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia?.('(max-width: 767px)');
|
||||
if (!media) return;
|
||||
const change = () => setNarrow(media.matches);
|
||||
media.addEventListener('change', change);
|
||||
return () => media.removeEventListener('change', change);
|
||||
}, []);
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
|
||||
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 });
|
||||
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const people = inbox.data?.people ?? [];
|
||||
const friends = people.filter(p => p.relationship === 'friend');
|
||||
const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name));
|
||||
useEffect(() => { if (initialTab) setTab(initialTab); }, [peer, initialTab]);
|
||||
const initialPeer = conversations[0]?.id;
|
||||
useEffect(() => {
|
||||
// Pin the initial desktop selection: a newer message in another dialog must
|
||||
// never switch the conversation the user is reading or composing in.
|
||||
if (!narrow && !peer && initialPeer) void navigate({ to: '/friends', search: { peer: initialPeer }, replace: true });
|
||||
}, [narrow, peer, initialPeer, navigate]);
|
||||
const selected = friends.find(p => p.id === peer) ?? (!peer ? conversations[0] : undefined);
|
||||
const requests = people.filter(p => p.relationship === 'incoming' || p.relationship === 'outgoing');
|
||||
const incoming = requests.filter(p => p.relationship === 'incoming').length;
|
||||
const messageUnread = friends.reduce((sum, p) => sum + p.unread, 0);
|
||||
const filtered = (tab === 'messages' ? conversations : requests).filter(p => `${p.display_name} ${p.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
|
||||
const shownPeople = tab === 'people' ? q.length >= 2 ? results.data ?? [] : friends : filtered;
|
||||
const open = (id: string) => {
|
||||
const person = people.find(p => p.id === id);
|
||||
setSearch(''); setQ('');
|
||||
setTab(person?.relationship === 'friend' ? 'messages' : person?.relationship === 'incoming' || person?.relationship === 'outgoing' ? 'requests' : 'people');
|
||||
void navigate({ to: '/friends', search: { peer: id } });
|
||||
};
|
||||
const findPeople = () => { setTab('people'); setSearch(''); setQ(''); requestAnimationFrame(() => searchRef.current?.focus()); };
|
||||
const back = () => { void navigate({ to: '/friends', search: {} }).then(() => listRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')?.focus()); };
|
||||
const act = (person: SocialPerson, op: SocialAction['action']) => {
|
||||
if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
|
||||
else action.mutate({ action: op, target_id: person.id });
|
||||
};
|
||||
const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search');
|
||||
return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
|
||||
<header className={s.pageHeader}>
|
||||
<h1>{t('social.title')}</h1>
|
||||
</header>
|
||||
<ListeningPanel showPreferences={false} />
|
||||
<section className={s.hub}>
|
||||
<nav className={s.tabs} aria-label={t('social.title')}>
|
||||
{(['messages', 'people', 'requests'] as const).map(value => <button key={value} type="button" aria-pressed={tab === value} onClick={() => { setTab(value); setSearch(''); setQ(''); }}>{t(`social.${value}`)}{(value === 'requests' ? incoming : value === 'messages' ? messageUnread : 0) > 0 && <span className={s.badge}>{value === 'requests' ? incoming : messageUnread}</span>}</button>)}
|
||||
<Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
|
||||
</nav>
|
||||
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
|
||||
<div className={tab === 'messages' ? s.workspace : s.directory}>
|
||||
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}>
|
||||
<div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div>
|
||||
{inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>}
|
||||
{tab === 'people' && q.length >= 2 && results.isFetching && <p className={s.muted} role="status">{t('social.loading')}</p>}
|
||||
{!shownPeople.length && inbox.data && !results.isFetching && <EmptyState icon={tab === 'requests' ? inboxIcon : groupIcon} action={!search && tab !== 'people' ? <Button onClick={findPeople}>{t('social.find_people')}</Button> : undefined}>{t(search ? 'social.no_results' : tab === 'requests' ? 'social.no_requests' : 'social.empty')}</EmptyState>}
|
||||
{shownPeople.map(p => tab === 'messages' ? <button key={p.id} type="button" className={`${s.dialogRow} ${selected?.id === p.id ? s.selected : ''}`} aria-pressed={selected?.id === p.id} onClick={() => open(p.id)}>
|
||||
<span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span>
|
||||
<span className={s.dialogBody}><span className={s.dialogTitle}><strong>{p.display_name}</strong>{p.last_message && <time>{new Date(p.last_message.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'short' })}</time>}</span><span className={s.preview}>{p.last_message ? `${p.last_message.sender_id === user.id ? `${t('social.you')}: ` : ''}${p.last_message.deleted ? t('social.message_deleted') : p.last_message.attachment_kind !== 'none' ? t(p.last_message.attachment_kind === 'track' ? 'social.shared_track' : 'social.shared_playlist') : p.last_message.text}` : t('social.start_conversation')}</span><Presence online={p.online} />{p.activity && <span className={s.listeningPreview}><Icon src={musicIcon} size={13} />{p.activity.track_title}</span>}</span>
|
||||
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
|
||||
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
|
||||
</aside>
|
||||
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !dialog && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
|
||||
</div>
|
||||
</section>
|
||||
{confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FriendMenu({ person, act }: { person: SocialPerson; act: (a: SocialAction['action']) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return <Menu label={t('social.person_actions', { name: person.display_name })} trigger={<Icon src={moreIcon} />} items={[
|
||||
...(person.relationship === 'friend' ? [{ label: t('social.remove'), danger: true, onSelect: () => act('remove') }] : []),
|
||||
{ label: t(person.relationship === 'blocked' ? 'social.unblock' : 'social.block'), danger: person.relationship !== 'blocked', onSelect: () => act(person.relationship === 'blocked' ? 'unblock' : 'block') },
|
||||
]} />;
|
||||
}
|
||||
|
||||
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected?: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
|
||||
<div className={s.identity}><span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div>{p.relationship === 'friend' && <Presence online={p.online} />}</div></div>
|
||||
{p.activity && <div className={s.activity}><span className={s.listening}><Icon src={musicIcon} size={16} />{t('social.listening')}</span><Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}</div>}
|
||||
<div className={s.actions}>
|
||||
{p.relationship === 'friend' && <><Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button><ListenTogetherButton person={p} /></>}
|
||||
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
|
||||
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
|
||||
{['incoming', 'outgoing'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : 'social.cancel')}</Button>}
|
||||
<FriendMenu person={p} act={act} />
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.dialog { width: 600px; max-height: min(680px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
|
||||
.dialog > :first-child { flex-shrink: 0; padding: 20px 24px 16px; }
|
||||
.dialog > :nth-child(2) { display: flex; flex-direction: column; gap: 0; min-height: 0; padding: 0; overflow: hidden; }
|
||||
.filters { display: flex; gap: 6px; flex-shrink: 0; padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
|
||||
.filters button { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 12px; border-radius: 9px; color: var(--text2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.filters button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
|
||||
.filters button:hover { background: var(--hover); color: var(--text); }
|
||||
.count { font-size: 11px; font-variant-numeric: tabular-nums; color: var(--accent2); }
|
||||
.list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line2) transparent; padding: 8px 12px 12px; }
|
||||
.row { position: relative; display: flex; align-items: center; border-radius: 12px; }
|
||||
.row + .row { margin-top: 4px; }
|
||||
.row.unread { background: var(--accent-soft); }
|
||||
.row:hover { background: var(--hover); }
|
||||
.open { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; padding: 14px 12px; border-radius: 12px; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
|
||||
.avatar { display: grid; place-items: center; flex-shrink: 0; width: 40px; height: 40px; border: 1px solid var(--line2); border-radius: 50%; background: var(--surface2); color: var(--accent2); font-size: 14px; font-weight: 600; }
|
||||
.content { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 4px; overflow-wrap: anywhere; }
|
||||
.content strong { font-size: 13px; font-weight: 600; }
|
||||
.content > span { font-size: 13px; color: var(--text2); line-height: 1.45; }
|
||||
.content time { color: var(--text3); font-size: 11px; line-height: 1.4; }
|
||||
.arrow { flex-shrink: 0; color: var(--text3); }
|
||||
.read { position: relative; display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; margin-right: 10px; border-radius: 9px; color: var(--accent2); cursor: pointer; }
|
||||
.read > svg, .read > span:last-child { opacity: 0; }
|
||||
.dot { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); }
|
||||
.read:hover, .read:focus-visible { background: var(--accent-soft); }
|
||||
.read:hover .dot, .read:focus-visible .dot { opacity: 0; }
|
||||
.read:hover > :last-child, .read:focus-visible > :last-child { opacity: 1; }
|
||||
.read:disabled { opacity: .5; cursor: wait; }
|
||||
.open:focus-visible, .read:focus-visible, .filters button:focus-visible, .list:focus-visible { outline: 2px solid var(--accent2); outline-offset: -2px; }
|
||||
.loading { display: grid; gap: 8px; padding: 12px; }
|
||||
.empty { padding: 36px 20px; }
|
||||
@media(max-width: 767px) {
|
||||
.dialog { max-height: min(680px, calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))); }
|
||||
.dialog > :first-child { padding: 16px; }
|
||||
.filters { padding: 0 16px 12px; }
|
||||
.list { padding: 6px; }
|
||||
.open { gap: 10px; padding: 12px 10px; }
|
||||
.avatar { width: 34px; height: 34px; }
|
||||
.read { width: 36px; min-height: 44px; margin-right: 4px; }
|
||||
}
|
||||
@media(hover: none) { .read > :last-child { opacity: 1; } .read .dot { display: none; } }
|
||||
@@ -0,0 +1,20 @@
|
||||
import { socialApi } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
import { useDesktop } from '../../desktop';
|
||||
|
||||
const scopes = new WeakMap<object, number>();
|
||||
let nextScope = 0;
|
||||
|
||||
export function useSocial() {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const server = useDesktop()?.server ?? '';
|
||||
const api = useMemo(() => socialApi(session), [session]);
|
||||
if (!scopes.has(session)) scopes.set(session, ++nextScope);
|
||||
const key = ['social', server, scopes.get(session), user.id] as const;
|
||||
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
|
||||
return { api, key, inbox, user };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; }
|
||||
.page h1 { font-size: 24px; letter-spacing: -.035em; margin: 0; }
|
||||
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; }
|
||||
.hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); }
|
||||
.tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; }
|
||||
.tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; }
|
||||
.tabs > button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
|
||||
.tabs > button[aria-pressed='true']::after { content: ''; position: absolute; bottom: -11px; height: 2px; left: 16px; right: 16px; background: var(--accent2); border-radius: 2px; animation: tabIn .2s ease-out; }
|
||||
.tabs > button:hover { color: var(--text); background: var(--hover); }
|
||||
.addFriend { margin-left: auto; }
|
||||
.badge { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--accent-fill); color: var(--on-accent, #fff); font-size: 11px; font-weight: 600; flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(230px, 29%) minmax(0, 1fr); }
|
||||
.peopleList { padding: 12px 10px; min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
|
||||
.workspace > .peopleList { border-right: 1px solid var(--line); background: var(--bg2); }
|
||||
.listSearch { display: flex; align-items: center; gap: 10px; background: var(--surface2); border: 1px solid var(--line); border-radius: 10px; padding: 0 12px; min-height: 44px; margin: 2px 4px 14px; color: var(--text3); }
|
||||
.listSearch:focus-within { border-color: var(--accent); }
|
||||
.listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; }
|
||||
.listSearch input::placeholder { color: var(--text3); }
|
||||
.listSearch input:focus-visible { outline: none; }
|
||||
.dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; }
|
||||
.dialogRow:hover { background: var(--hover); }
|
||||
.dialogRow:active { transform: scale(.985); }
|
||||
.dialogRow.selected { background: var(--accent-soft); }
|
||||
.dialogBody { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
|
||||
.dialogTitle { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
|
||||
.dialogTitle strong { font-size: 14px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.dialogTitle time { color: var(--text3); font-size: 10px; white-space: nowrap; flex-shrink: 0; }
|
||||
.preview { color: var(--text2); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.listeningPreview { display: flex; align-items: center; gap: 4px; color: var(--ok); font-size: 11px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.avatar { position: relative; width: 42px; height: 42px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); font-size: 16px; font-weight: 600; }
|
||||
.avatarListening::after { content: ''; position: absolute; right: 0; bottom: 0; width: 10px; height: 10px; background: var(--ok); border-radius: 50%; border: 2px solid var(--surface); }
|
||||
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
|
||||
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
|
||||
.presenceOnline .presenceDot { background: var(--ok); }
|
||||
.directory { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
|
||||
.directory .peopleList { padding: 0; overflow: visible; }
|
||||
.directory .listSearch { max-width: 440px; margin-left: 0; }
|
||||
.person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; }
|
||||
.person > .actions { margin-left: auto; }
|
||||
.person.selected { background: var(--accent-soft); }
|
||||
.identity { display: flex; gap: 12px; align-items: center; min-width: 0; }
|
||||
.identity > div { min-width: 0; }
|
||||
.identity strong { display: block; overflow-wrap: anywhere; }
|
||||
.activity { display: grid; gap: 4px; max-width: 300px; font-size: 13px; }
|
||||
.activity a, .nowListening a { color: var(--text); text-decoration: none; }
|
||||
.activity a:hover, .nowListening a:hover { color: var(--accent2); text-decoration: underline; }
|
||||
.listening { display: flex; align-items: center; gap: 5px; color: var(--ok); font-size: 11px; }
|
||||
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.stack { display: grid; gap: 12px; }
|
||||
.stack:empty { display: none; }
|
||||
.welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; padding: 32px; }
|
||||
.welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; }
|
||||
.welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; }
|
||||
.welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; }
|
||||
.privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; }
|
||||
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
|
||||
.privacy summary { cursor: pointer; font-weight: 600; }
|
||||
.privacy p { margin-bottom: 0; }
|
||||
.privacy .privacy { margin: 12px 0; }
|
||||
.privacy input { accent-color: var(--accent); }
|
||||
.preferencesDialog { width: 560px; max-height: min(720px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
|
||||
.preferencesDialog > :first-child { padding: 20px 24px 16px; flex-shrink: 0; border-bottom: 1px solid var(--line); }
|
||||
.preferencesDialog > :nth-child(2) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
|
||||
.preferencesDialog fieldset.privacy { border: 0; padding: 0; margin: 0; }
|
||||
.preferencesDialog .privacy legend { padding: 0; margin-bottom: 12px; color: var(--text); font-weight: 600; }
|
||||
.preferencesDialog .privacy .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); }
|
||||
.preferencesDialog .privacy .actions button { min-width: 0; padding: 0 8px; border-radius: 8px; min-height: 36px; }
|
||||
.preferencesDialog details.privacy { padding: 16px; margin: 8px 0 0; border: 1px solid var(--line); background: var(--surface2); }
|
||||
.preferencesDialog details.privacy > summary { line-height: 1.5; }
|
||||
.preferencesDialog details.privacy fieldset { margin: 20px 0; }
|
||||
.preferencesDialog details.privacy > label { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.5; }
|
||||
.preferencesDialog details.privacy > label input { margin-top: 3px; }
|
||||
.choice { display: flex; gap: 12px; padding: 14px; align-items: center; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--surface2); font-size: 13px; line-height: 1.5; }
|
||||
.choice + .choice { margin-top: 8px; }
|
||||
.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.choice:has(input:disabled) { opacity: .5; cursor: not-allowed; }
|
||||
.choice input { flex-shrink: 0; accent-color: var(--accent); }
|
||||
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
|
||||
.chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); }
|
||||
.chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
||||
.chatIdentity { flex: 1; min-width: 0; }
|
||||
.chatIdentity h2 { font-size: 16px; line-height: 1.4; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chatIdentity > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.chatIdentity > .presence { display: inline-flex; }
|
||||
.chatTools { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
.iconButton { width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 12px; flex-shrink: 0; }
|
||||
.backButton { display: none; }
|
||||
.nowListening { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); color: var(--ok); flex-shrink: 0; }
|
||||
.nowListening > div { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 12px; }
|
||||
.nowListening span { font-size: 11px; }
|
||||
.nowListening a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||
.chatTabs { display: flex; gap: 16px; padding: 0 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
||||
.chatTabs button { position: relative; display: flex; align-items: center; gap: 6px; padding: 12px 0; font-size: 12px; color: var(--text3); transition: color .16s; }
|
||||
.chatTabs button[aria-pressed='true'] { color: var(--text); }
|
||||
.chatTabs button[aria-pressed='true']::after { content: ''; height: 2px; position: absolute; left: 0; right: 0; bottom: -1px; border-radius: 2px; background: var(--accent2); animation: tabIn .18s ease-out; }
|
||||
.chatSearch { display: flex; gap: 12px; align-items: end; padding: 12px 20px; border-bottom: 1px solid var(--line); animation: panelIn .18s ease-out; flex-shrink: 0; }
|
||||
.chatSearch > div { flex: 1; }
|
||||
.messages { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; scrollbar-width: thin; padding: 22px 28px; }
|
||||
.chatSkeleton { display: flex; flex-direction: column; gap: 20px; }
|
||||
.older { align-self: center; flex-shrink: 0; }
|
||||
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0 10px; flex-shrink: 0; }
|
||||
.newMessages { text-align: center; color: var(--accent2); font-size: 11px; border-bottom: 1px solid var(--line2); padding: 6px; }
|
||||
.bubble { display: flex; flex-direction: column; gap: 7px; padding: 10px 14px 5px; border-radius: 16px 16px 16px 4px; background: var(--surface2); max-width: min(82%, 560px); min-width: 100px; flex-shrink: 0; align-self: start; overflow-wrap: anywhere; animation: messageIn .22s cubic-bezier(.2,.8,.2,1); }
|
||||
.mine { align-self: end; background: var(--accent-soft); border-radius: 16px 16px 4px 16px; }
|
||||
.messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; }
|
||||
.messageText a, .attachmentCaption a { color: var(--accent2); }
|
||||
.messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; }
|
||||
.messageFoot small { font-size: 11px; color: var(--text2); }
|
||||
.messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); }
|
||||
.messageFoot button:hover { color: var(--text); background: var(--hover); }
|
||||
.quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; }
|
||||
.deleted { font-style: italic; color: var(--text3); }
|
||||
.sending { opacity: .6; }
|
||||
.reactions { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--text); padding: 4px 8px; font-size: 12px; cursor: pointer; transition: transform .15s, background .15s; }
|
||||
.reactions button:hover { transform: translateY(-2px); }
|
||||
.reactions button[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent2); }
|
||||
.typing { display: flex; align-items: center; gap: 8px; min-height: 24px; padding: 0 24px 4px; color: var(--text3); font-size: 11px; flex-shrink: 0; }
|
||||
.typingDots { display: flex; gap: 3px; }
|
||||
.typingDots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: typingPulse 1s ease-in-out infinite alternate; }
|
||||
.typingDots i:nth-child(2) { animation-delay: .15s; }
|
||||
.typingDots i:nth-child(3) { animation-delay: .3s; }
|
||||
.replyBar { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .16s ease-out; }
|
||||
.replyBar > div { flex: 1; min-width: 0; }
|
||||
.replyBar small { color: var(--accent2); }
|
||||
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; overflow-wrap: anywhere; }
|
||||
.composer { display: flex; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--line); align-items: end; flex-shrink: 0; background: var(--surface); }
|
||||
.composer textarea { resize: none; min-width: 0; flex: 1; border: 1px solid transparent; border-radius: 12px; background: var(--surface2); color: var(--text); padding: 10px 14px; min-height: 42px; max-height: 144px; font: inherit; font-size: 14px; line-height: 22px; scrollbar-width: thin; transition: border-color .18s, background .18s; }
|
||||
.composer textarea:focus { border-color: var(--accent); }
|
||||
.composer textarea::placeholder { color: var(--text3); }
|
||||
.emojiPicker { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 20px; border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .18s ease-out; }
|
||||
.emojiPicker button { width: 40px; height: 40px; border-radius: 10px; font-size: 20px; transition: background .15s, transform .15s; }
|
||||
.emojiPicker button:hover { background: var(--hover); transform: translateY(-2px); }
|
||||
.jumpToBottom { position: absolute; bottom: 94px; align-self: center; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); z-index: 2; animation: panelIn .18s ease-out; }
|
||||
.musicCard { display: flex; align-items: center; gap: 12px; min-width: 0; width: 320px; max-width: 100%; padding: 6px 0; }
|
||||
.musicInfo { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
|
||||
.musicInfo a { color: var(--text); font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.musicInfo a:hover { color: var(--accent2); text-decoration: underline; }
|
||||
.musicInfo > span { font-size: 11px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.musicCover { width: 48px; height: 48px; display: grid; place-items: center; background: var(--surface3); border-radius: 8px; color: var(--text3); flex-shrink: 0; }
|
||||
.musicUnavailable { opacity: .7; }
|
||||
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.musicMessages .musicCard { width: 100%; }
|
||||
.miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; }
|
||||
.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); overflow: hidden; animation: panelIn .2s ease-out; }
|
||||
.miniDockIsland { margin-bottom: 132px; }
|
||||
.miniDockIsland .miniPanel { height: min(640px, calc(100dvh - 290px)); }
|
||||
.miniPanel[hidden] { display: none; }
|
||||
.miniHeader { display: flex; align-items: center; gap: 6px; padding: 8px 10px 8px 16px; border-bottom: 1px solid var(--line); }
|
||||
.miniHeader > strong { flex: 1; font-size: 14px; }
|
||||
.miniHeader > button { min-height: 36px; padding: 0 8px; font-size: 12px; }
|
||||
.miniWorkspace { flex: 1; min-height: 0; display: grid; grid-template-columns: 170px minmax(0, 1fr); }
|
||||
.miniFriends { overflow-y: auto; min-width: 0; padding: 8px; border-right: 1px solid var(--line); }
|
||||
.miniFriends .listSearch { padding: 0 8px; gap: 5px; margin: 0 0 8px; min-height: 36px; }
|
||||
.miniFriends .listSearch input { height: 36px; font-size: 11px; }
|
||||
.miniFriends .dialogRow { gap: 8px; padding: 10px 6px; }
|
||||
.miniFriends .avatar { width: 28px; height: 28px; font-size: 12px; }
|
||||
.miniFriends .dialogBody strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
|
||||
.miniFriends .badge { min-width: 17px; height: 17px; padding: 0 4px; font-size: 10px; }
|
||||
.miniWorkspace .chat { min-width: 0; }
|
||||
.miniWorkspace .chatHeader { gap: 8px; padding: 10px; }
|
||||
.miniWorkspace .chatHeader .avatar { width: 30px; height: 30px; font-size: 12px; }
|
||||
.miniWorkspace .chatIdentity h2 { font-size: 14px; }
|
||||
.miniWorkspace .chatTools .togetherLabel { display: none; }
|
||||
.miniWorkspace .chatTools > button, .miniWorkspace .chatTools > div > button { width: 34px; min-width: 34px; padding: 0; }
|
||||
.miniWorkspace .chatTabs { gap: 14px; padding: 0 12px; }
|
||||
.miniWorkspace .chatTabs button { font-size: 11px; }
|
||||
.miniWorkspace .messages { padding: 12px; gap: 10px; }
|
||||
.miniWorkspace .bubble { max-width: 94%; font-size: 13px; padding: 8px 10px 4px; }
|
||||
.miniWorkspace .composer { padding: 8px; gap: 4px; }
|
||||
.miniWorkspace .composer .iconButton { width: 34px; min-width: 34px; }
|
||||
.miniWorkspace .composer textarea { padding: 8px; font-size: 13px; }
|
||||
.miniWorkspace .welcome { padding: 20px; }
|
||||
.miniTrigger { position: relative; pointer-events: auto; display: grid; place-items: center; width: 48px; height: 48px; flex-shrink: 0; border-radius: 50%; color: var(--text); border: 1px solid var(--line2); background: var(--surface); box-shadow: 0 6px 24px #0005; cursor: pointer; }
|
||||
.miniTrigger:hover, .miniTrigger[aria-expanded='true'] { background: var(--accent-fill); color: var(--on-accent, #fff); }
|
||||
.miniTrigger:focus-visible { outline: 2px solid var(--accent2); outline-offset: 3px; }
|
||||
.miniTrigger > .badge { position: absolute; top: -5px; right: -5px; }
|
||||
@media(max-width: 1023px) { .miniDock { display: none; } }
|
||||
@media(prefers-reduced-motion: reduce) { .miniPanel { animation: none; } }
|
||||
.attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; }
|
||||
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; }
|
||||
@keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes panelIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
@keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } }
|
||||
@keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } }
|
||||
@media(max-width: 1150px) { .pageHeader { align-items: start; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
|
||||
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
|
||||
@media(max-width: 767px) {
|
||||
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .page h1 { font-size: 23px; }
|
||||
.hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; }
|
||||
.workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; }
|
||||
.conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
|
||||
.conversationOpen .workspace > .chat { display: flex; flex: 1; } .backButton { display: inline-flex; } .chatHeader { padding: 10px 8px; gap: 6px; }
|
||||
.chatHeader .avatar { width: 32px; height: 32px; font-size: 13px; } .chatIdentity h2 { font-size: 14px; } .chatIdentity > span { font-size: 11px; }
|
||||
.chatTools > button, .chatTools > div > button { min-width: 36px; width: 36px; } .iconButton { min-width: 36px; width: 36px; } .chatTools > div:last-child > button { width: 32px; min-width: 32px; }
|
||||
.chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; }
|
||||
.nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; }
|
||||
.composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; }
|
||||
.directory { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; }
|
||||
.musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; }
|
||||
.chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; }
|
||||
}
|
||||
@media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } }
|
||||
@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } }
|
||||
@@ -0,0 +1,68 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||
import s from '../../pages/page.module.css';
|
||||
import { downloadUserArchive, MAX_ARCHIVE_BYTES, parseUserArchive, type UserArchive } from './transfer';
|
||||
|
||||
export function UserTransfer() {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [archive, setArchive] = useState<UserArchive | null>(null);
|
||||
const [fileError, setFileError] = useState(false);
|
||||
const [reading, setReading] = useState(false);
|
||||
const exporting = useMutation({
|
||||
mutationFn: async () => downloadUserArchive(unwrap(await session.client.GET('/api/v1/admin/users/export'))),
|
||||
});
|
||||
const importing = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/users/import', { body: archive! })),
|
||||
onSuccess: () => { setArchive(null); void queryClient.invalidateQueries({ queryKey: ['admin-users'] }); },
|
||||
});
|
||||
const busy = importing.isPending || reading;
|
||||
const close = () => { if (!busy) { setOpen(false); setArchive(null); } };
|
||||
|
||||
const read = async (file: File | undefined) => {
|
||||
setArchive(null);
|
||||
setFileError(false);
|
||||
importing.reset();
|
||||
if (!file) return;
|
||||
setReading(true);
|
||||
try {
|
||||
if (file.size > MAX_ARCHIVE_BYTES) throw new Error('size');
|
||||
setArchive(parseUserArchive(await file.text()));
|
||||
} catch { setFileError(true); }
|
||||
finally { setReading(false); }
|
||||
};
|
||||
|
||||
return <>
|
||||
<Button busy={exporting.isPending} onClick={() => exporting.mutate()}>{t('transfer.export')}</Button>
|
||||
<Button onClick={() => { importing.reset(); setFileError(false); setOpen(true); }}>{t('transfer.import')}</Button>
|
||||
<FormError>{exporting.error ? errorText(exporting.error) : null}</FormError>
|
||||
{open && <Dialog title={t('transfer.import')} onClose={close} footer={<>
|
||||
<Button variant="ghost" disabled={busy} onClick={close}>{t('common:form.close')}</Button>
|
||||
<Button variant="primary" busy={importing.isPending} disabled={!archive || busy} onClick={() => importing.mutate()}>
|
||||
{t('transfer.apply')}
|
||||
</Button>
|
||||
</>}>
|
||||
<p className={s.note}>{t('transfer.description')}</p>
|
||||
<p className={s.note}>{t('transfer.sensitive')}</p>
|
||||
<label className={s.sessions}>
|
||||
<span>{t('transfer.file')}</span>
|
||||
<input type="file" accept=".json,application/json" disabled={busy} onChange={(e) => void read(e.target.files?.[0])} />
|
||||
</label>
|
||||
{archive && <p className={s.note}>{t('transfer.preview', { count: archive.users.length })}</p>}
|
||||
<FormError>{fileError ? t('transfer.invalid') : importing.error ? errorText(importing.error) : null}</FormError>
|
||||
{importing.data && <div role="status">
|
||||
<p>{t('transfer.result', { count: importing.data.created, skipped: importing.data.skipped.length })}</p>
|
||||
{importing.data.skipped.length > 0 && <p className={s.note}>{importing.data.skipped.join(', ')}</p>}
|
||||
</div>}
|
||||
</Dialog>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseUserArchive } from './transfer';
|
||||
|
||||
describe('user archive preview', () => {
|
||||
const entry = { username: 'alice', role: 'user', password_hash: '$argon2id$hash' };
|
||||
it('accepts a versioned export and a UTF-8 BOM', () => {
|
||||
const archive = { version: 1, users: [entry] };
|
||||
expect(parseUserArchive(`\uFEFF${JSON.stringify(archive)}`)).toEqual(archive);
|
||||
});
|
||||
it.each([
|
||||
'{}', '{"version":2,"users":[]}', '{"version":1,"users":{}}',
|
||||
JSON.stringify({ version: 1, users: [{ ...entry, role: 'owner' }] }),
|
||||
JSON.stringify({ version: 1, users: [{ username: 'alice', role: 'user' }] }),
|
||||
JSON.stringify({ version: 1, users: Array(10001).fill(entry) }),
|
||||
])('rejects unsupported or malformed input', (text) => {
|
||||
expect(() => parseUserArchive(text)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
|
||||
export type UserArchive = components['schemas']['UserArchive'];
|
||||
export const MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Only previews metadata; the server validates every account before writing. */
|
||||
export function parseUserArchive(text: string): UserArchive {
|
||||
const data: unknown = JSON.parse(text.replace(/^\uFEFF/, ''));
|
||||
if (!data || typeof data !== 'object' || !('version' in data) || data.version !== 1 ||
|
||||
!('users' in data) || !Array.isArray(data.users) || data.users.length > 10000) throw new Error('archive');
|
||||
for (const entry of data.users) {
|
||||
if (!entry || typeof entry !== 'object' || typeof entry.username !== 'string' ||
|
||||
!['admin', 'moderator', 'user'].includes(entry.role) || typeof entry.password_hash !== 'string') {
|
||||
throw new Error('archive');
|
||||
}
|
||||
}
|
||||
return data as UserArchive;
|
||||
}
|
||||
|
||||
export function downloadUserArchive(archive: UserArchive) {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(archive, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'pipu-users.json';
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import { App } from './App';
|
||||
import { createAppRouter } from './app/router';
|
||||
import { applyTheme } from './app/session';
|
||||
import { createDesktop, DesktopProvider } from './desktop';
|
||||
import { createDesktop, DesktopProvider, desktopFetch } from './desktop';
|
||||
|
||||
const LOCALE_KEY = 'pipu.locale';
|
||||
|
||||
@@ -70,6 +70,7 @@ const session = new AuthSession({
|
||||
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||
baseUrl: desktop?.proxyBase,
|
||||
refreshStore: desktop?.refreshStore,
|
||||
fetch: desktop ? desktopFetch : undefined,
|
||||
});
|
||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||
await session.restore();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -9,7 +9,9 @@ import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { sharePlaylist } from '../shared/lib/share';
|
||||
import { playlistShareUrl } from '@pipu/core';
|
||||
import { desktopHandle } from '../desktop';
|
||||
import { useMusicShare } from '../features/social/Social';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
|
||||
@@ -55,7 +57,21 @@ export function PlaylistPage({ id, shared = false }: { id: string; shared?: bool
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
|
||||
export function ActivityPlaylistPage({ userId }: { userId: string }) {
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery({ queryKey: ['activity-playlist', session.state.status === 'signed_in' ? session.state.user.id : '', userId], queryFn: async () => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })), staleTime: 0, refetchInterval: 15_000 });
|
||||
return <div className={s.page}>
|
||||
<Link to="/friends">← {t('social.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <PageSkeleton />}
|
||||
{query.data && !query.isError && <PlaylistDetail key={query.data.id} playlist={query.data} readOnly activity />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist, readOnly = false, shareId, activity = false }: { playlist: Playlist; readOnly?: boolean; shareId?: string; activity?: boolean }) {
|
||||
const openShare = useMusicShare();
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
@@ -65,20 +81,17 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const sharing = useMutation({ mutationFn: async () => {
|
||||
setCopied(false);
|
||||
let token = shareId ?? playlist.share_id;
|
||||
if (!readOnly) {
|
||||
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
|
||||
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
|
||||
}
|
||||
if (!token) throw new Error('Share link unavailable');
|
||||
const result = await sharePlaylist(token, playlist.name);
|
||||
if (result === 'copied') setCopied(true);
|
||||
openShare({ attachment: { kind: 'playlist', id: token, title: playlist.name }, url: playlistShareUrl(desktopHandle()?.server ?? window.location.origin, token) });
|
||||
} });
|
||||
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
|
||||
onSuccess: () => { setCopied(false); cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
|
||||
onSuccess: () => { cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
|
||||
|
||||
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||||
@@ -108,7 +121,7 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
|
||||
<div className={s.toolbar}>
|
||||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
|
||||
{!activity && <Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>}
|
||||
{!readOnly && <>
|
||||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||
@@ -117,7 +130,6 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
|
||||
</>}
|
||||
</div>
|
||||
{!readOnly && <p>{t('playlists.share_hint')}</p>}
|
||||
{copied && <p role="status">{t('playlists.link_copied')}</p>}
|
||||
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
{!playlist.tracks.length && (
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import s from './page.module.css';
|
||||
import { Onboarding } from './Onboarding';
|
||||
import { FriendRecommendations } from '../features/social/Social';
|
||||
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
|
||||
|
||||
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
|
||||
@@ -27,6 +29,8 @@ export function SettingsPage() {
|
||||
<ProfileSection />
|
||||
<RecommendationSettings />
|
||||
<AppearanceSection />
|
||||
<DesktopOfflineSection />
|
||||
<DesktopUpdateButton />
|
||||
<PasswordSection />
|
||||
<SessionsSection />
|
||||
</div>
|
||||
@@ -368,6 +372,7 @@ function RecommendationSettings() {
|
||||
const errorText = useErrorText();
|
||||
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
|
||||
return <section className={s.section}>
|
||||
<FriendRecommendations />
|
||||
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
|
||||
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
|
||||
<FormError>{options.error ? errorText(options.error) : null}</FormError>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { SessionProvider } from '../../app/session';
|
||||
import { mobileFixtures } from '../../test/mobileFixtures';
|
||||
import { SourceDialog } from './SourcesPage';
|
||||
|
||||
afterEach(cleanup);
|
||||
type Source = components['schemas']['Source'];
|
||||
const base = (mobileFixtures['/api/v1/admin/sources'] as { items: Source[] }).items[0]!;
|
||||
|
||||
async function fixture(source: Source | null = { ...base, excluded_dirs: ['Downloads_PumpEars'] }, reject = false) {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
if (req.method === 'PATCH' || req.method === 'POST') {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
bodies.push(body);
|
||||
return new Response(JSON.stringify(reject
|
||||
? { code: 'validation.excluded_dirs_invalid', errors: [{ field: 'excluded_dirs', code: 'validation.excluded_dirs_invalid' }] }
|
||||
: { ...base, ...body }), { status: reject ? 422 : 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({ path: '', dirs: [] }), { headers: { 'Content-Type': 'application/json' } });
|
||||
} });
|
||||
const i18n = await createI18n('ru');
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const close = vi.fn();
|
||||
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
|
||||
<SourceDialog source={source} onClose={close} />
|
||||
</QueryClientProvider></SessionProvider></I18nextProvider>);
|
||||
return { bodies, close, user: userEvent.setup() };
|
||||
}
|
||||
|
||||
it('loads and saves one excluded path per line', async () => {
|
||||
const { bodies, close, user } = await fixture();
|
||||
const input = screen.getByLabelText('Не сканировать папки');
|
||||
expect(input).toHaveValue('Downloads_PumpEars');
|
||||
await user.type(input, '\nArtist/Samples\n');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(bodies[0]?.excluded_dirs).toEqual(['Downloads_PumpEars', 'Artist/Samples']);
|
||||
});
|
||||
|
||||
it('clears the list explicitly so excluded folders can be scanned again', async () => {
|
||||
const { bodies, close, user } = await fixture();
|
||||
await user.clear(screen.getByLabelText('Не сканировать папки'));
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(bodies[0]?.excluded_dirs).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps invalid paths in the editor and displays the server validation', async () => {
|
||||
const { user, close } = await fixture({ ...base, excluded_dirs: ['../outside'] }, true);
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(screen.getByLabelText('Не сканировать папки')).toHaveAttribute('aria-invalid', 'true'));
|
||||
expect(screen.getByLabelText('Не сканировать папки')).toHaveValue('../outside');
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useId, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
@@ -75,6 +75,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
const [test, setTest] = useState<TestResult | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const id = source.id;
|
||||
const covers = useQuery({
|
||||
queryKey: ['source-cover-stats', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}/cover-stats', { params: { path: { id } } })),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||
|
||||
const scan = useMutation({
|
||||
@@ -120,6 +125,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
|
||||
</div>
|
||||
<span className={l.location}>{location(source)}</span>
|
||||
{!!source.excluded_dirs?.length && <span className={s.note}>{t('sources.excluded_count', { count: source.excluded_dirs.length })}</span>}
|
||||
{source.status === 'error' && source.status_message && (
|
||||
<span className={l.message}>{source.status_message}</span>
|
||||
)}
|
||||
@@ -143,6 +149,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
</div>
|
||||
<div className={l.cardFoot}>
|
||||
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
|
||||
{covers.data && <span className={s.note} role="status">
|
||||
{t('sources.cover_files', { count: covers.data.files_with_cover, total: covers.data.files_total })}
|
||||
{covers.data.albums_pending > 0 && ` · ${t('sources.cover_pending', { count: covers.data.albums_pending })}`}
|
||||
</span>}
|
||||
<FormError>{covers.error ? errorText(covers.error) : null}</FormError>
|
||||
{test && (
|
||||
<span className={`${l.test} ${test.ok ? l.testOk : l.testFail}`} role="status">
|
||||
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
|
||||
@@ -185,7 +196,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
|
||||
const INTERVALS = [0, 60, 360, 1440];
|
||||
|
||||
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
|
||||
export function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -199,12 +210,15 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
|
||||
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
|
||||
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
|
||||
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
|
||||
const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n'));
|
||||
const excludedDirsId = useId();
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const common = {
|
||||
name: name.trim(),
|
||||
tag_priority: priority,
|
||||
excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean),
|
||||
...(type === 'local'
|
||||
? { local_path: localPath }
|
||||
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
|
||||
@@ -224,11 +238,14 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||
for (const key of ['library-status', 'library-errors', 'analysis-errors']) {
|
||||
void queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
const err = save.error;
|
||||
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options'];
|
||||
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options', 'excluded_dirs'];
|
||||
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
@@ -297,6 +314,18 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className={l.field}>
|
||||
<label htmlFor={excludedDirsId} className={l.label}>{t('sources.excluded_dirs')}</label>
|
||||
<textarea id={excludedDirsId} className={l.excludedDirs} rows={5}
|
||||
value={excludedDirs} onChange={(event) => setExcludedDirs(event.target.value)}
|
||||
disabled={source?.scan?.running ?? false}
|
||||
placeholder="Downloads_PumpEars"
|
||||
aria-invalid={!!fieldError(err, 'excluded_dirs')}
|
||||
aria-describedby={`${excludedDirsId}-hint`} />
|
||||
<span id={`${excludedDirsId}-hint`} className={s.note}>
|
||||
{fieldError(err, 'excluded_dirs') ?? t(source?.scan?.running ? 'sources.excluded_running' : 'sources.excluded_hint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={l.field}>
|
||||
<span className={l.label}>{t('sources.tag_priority')}</span>
|
||||
<Segmented
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
import { UserTransfer } from '../../features/users/UserTransfer';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatRelative } from '../../shared/lib/format';
|
||||
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||
@@ -140,6 +141,11 @@ export function UsersPage() {
|
||||
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
|
||||
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
|
||||
});
|
||||
if (me.role === 'admin' && u.role !== 'admin')
|
||||
items.push({
|
||||
label: t(u.manages_updates ? 'users.revoke_updates' : 'users.grant_updates'),
|
||||
onSelect: () => patch.mutate({ id: u.id, body: { manages_updates: !u.manages_updates } }),
|
||||
});
|
||||
if (can('delete'))
|
||||
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
|
||||
return items;
|
||||
@@ -158,6 +164,7 @@ export function UsersPage() {
|
||||
</div>
|
||||
|
||||
<div className={s.toolbar}>
|
||||
{me.role === 'admin' && <UserTransfer />}
|
||||
<label className={s.search}>
|
||||
<Icon src={searchIcon} size={18} className={s.searchIcon} />
|
||||
<input
|
||||
@@ -214,6 +221,7 @@ export function UsersPage() {
|
||||
</td>
|
||||
<td data-label={t('users.col_role')}>
|
||||
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
|
||||
{u.manages_updates && <span className={cx(s.badge, s.role_moderator)}>{t('users.manage_updates')}</span>}
|
||||
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
|
||||
</td>
|
||||
<td data-label={t('users.col_status')}>
|
||||
|
||||
@@ -238,6 +238,20 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.excludedDirs {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font: 13px var(--font-mono);
|
||||
}
|
||||
.excludedDirs:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.excludedDirs:disabled { opacity: 0.6; }
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { AuthSession, ListeningState, Track } from '@pipu/core';
|
||||
import { refreshListening } from '@pipu/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Player } from './player';
|
||||
|
||||
const track: Track = {
|
||||
id: 'local', title: 'Local', duration_ms: 200_000, artists: [],
|
||||
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [], disc_no: null, track_no: null, available: true, stream_url: '/s/local', reaction: null, features: null,
|
||||
};
|
||||
const remote = { ...track, id: 'remote', stream_url: '/s/remote' };
|
||||
const ok = (data: unknown) => ({ data, response: new Response() });
|
||||
const players: Player[] = [];
|
||||
|
||||
function fixture() {
|
||||
let snapshot: ListeningState = { room: null, requests: [], preferences: { requests: 'friends', auto_accept: 'friends', autoplay: true }, server_time: new Date().toISOString() };
|
||||
const session = {
|
||||
state: { status: 'signed_in', user: { id: 'guest', ui_prefs: { crossfade_s: 6, normalize: false } } },
|
||||
client: {
|
||||
GET: vi.fn(async (path: string) => ok(path === '/api/v1/me/social/listening' ? snapshot : remote)),
|
||||
PUT: vi.fn(async () => ok({})), POST: vi.fn(async () => ok({})),
|
||||
},
|
||||
} as unknown as AuthSession;
|
||||
const audio = document.createElement('audio');
|
||||
const overlap = document.createElement('audio');
|
||||
vi.spyOn(globalThis, 'Audio').mockImplementation(function () { return overlap; });
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frames.push(callback); return frames.length; });
|
||||
const clock = vi.spyOn(performance, 'now').mockReturnValue(0);
|
||||
const player = new Player(session, audio);
|
||||
players.push(player);
|
||||
const startFade = () => {
|
||||
player.playList([track, { ...track, id: 'next', stream_url: '/s/next' }], 0, { type: 'queue' });
|
||||
Object.defineProperty(audio, 'duration', { configurable: true, value: 200 });
|
||||
audio.currentTime = 196;
|
||||
audio.dispatchEvent(new Event('timeupdate'));
|
||||
clock.mockReturnValue(3000);
|
||||
frames.shift()!(3000);
|
||||
expect(overlap.paused).toBe(false);
|
||||
expect(overlap.volume).toBeGreaterThan(0);
|
||||
};
|
||||
const follow = async () => {
|
||||
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [{ id: 'host', name: 'Host' }, { id: 'guest', name: 'Guest' }] } };
|
||||
player.start();
|
||||
await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } }));
|
||||
};
|
||||
return { player, audio, overlap, frames, clock, startFade, follow, session, setRoom: (room: ListeningState['room']) => { snapshot = { ...snapshot, room }; } };
|
||||
}
|
||||
|
||||
afterEach(() => { players.splice(0).forEach(player => player.dispose()); vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
|
||||
|
||||
describe('shared listening audio ownership', () => {
|
||||
it('stops a local crossfade before following and ignores its queued animation', async () => {
|
||||
const e = fixture();
|
||||
e.startFade();
|
||||
const staleFrame = e.frames.shift()!;
|
||||
await e.follow();
|
||||
expect(e.audio.paused).toBe(false);
|
||||
expect(e.overlap.paused).toBe(true);
|
||||
expect(e.overlap.getAttribute('src')).toBeNull();
|
||||
staleFrame(6000);
|
||||
expect(e.player.getState().track?.id).toBe('remote');
|
||||
expect(e.overlap.paused).toBe(true);
|
||||
e.setRoom(null);
|
||||
refreshListening(e.session);
|
||||
await vi.waitFor(() => expect(e.player.getState().listening?.room).toBeNull());
|
||||
expect(e.audio.paused).toBe(true);
|
||||
expect(e.overlap.paused).toBe(true);
|
||||
});
|
||||
|
||||
it('silences the overlapping element on pause', () => {
|
||||
const e = fixture();
|
||||
e.startFade();
|
||||
e.player.pause();
|
||||
expect(e.audio.paused).toBe(true);
|
||||
expect(e.overlap.paused).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels an old transition when another track is selected', () => {
|
||||
const e = fixture();
|
||||
e.startFade();
|
||||
const staleFrame = e.frames.shift()!;
|
||||
e.player.playList([remote], 0, { type: 'queue' });
|
||||
e.clock.mockReturnValue(6000);
|
||||
staleFrame(6000);
|
||||
expect(e.audio.getAttribute('src')).toBe('/s/remote');
|
||||
expect(e.player.getState().track?.id).toBe('remote');
|
||||
expect(e.overlap.paused).toBe(true);
|
||||
});
|
||||
|
||||
it('still completes an uninterrupted local crossfade with one active source', async () => {
|
||||
const e = fixture();
|
||||
e.startFade();
|
||||
e.clock.mockReturnValue(6000);
|
||||
e.frames.shift()!(6000);
|
||||
await Promise.resolve();
|
||||
expect(e.player.getState().track?.id).toBe('next');
|
||||
expect(e.audio.getAttribute('src')).toBe('/s/next');
|
||||
expect(e.audio.paused).toBe(false);
|
||||
expect(e.overlap.paused).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
|
||||
import type { PlayerState } from './types';
|
||||
import { offlineOwner } from '../features/desktop/offline';
|
||||
|
||||
const LOCAL_KEY = 'pipu.player';
|
||||
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
|
||||
@@ -30,6 +31,7 @@ function deviceId(): string {
|
||||
}
|
||||
|
||||
export class PlaybackPersistence {
|
||||
private get localKey() { const owner = offlineOwner(this.session); return owner ? `${LOCAL_KEY}.${owner}` : LOCAL_KEY; }
|
||||
private readonly device = deviceId();
|
||||
lastServerSave = 0;
|
||||
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
|
||||
@@ -50,7 +52,7 @@ export class PlaybackPersistence {
|
||||
saveLocal() {
|
||||
if (!this.getState().track) return;
|
||||
try {
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState())));
|
||||
localStorage.setItem(this.localKey, JSON.stringify(this.snapshot(this.getState())));
|
||||
} catch {
|
||||
// недоступное хранилище не мешает воспроизведению
|
||||
}
|
||||
@@ -91,7 +93,7 @@ export class PlaybackPersistence {
|
||||
async restore(): Promise<Saved | null> {
|
||||
let local: Saved | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_KEY);
|
||||
const raw = localStorage.getItem(this.localKey);
|
||||
local = raw ? (JSON.parse(raw) as Saved) : null;
|
||||
} catch {
|
||||
local = null;
|
||||
|
||||
+150
-16
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
createActivityPublisher,
|
||||
type ActivityPublisher,
|
||||
ListeningCoordinator,
|
||||
type ListeningRoom,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveOutcome,
|
||||
@@ -18,6 +22,7 @@ import { MediaControls } from './media';
|
||||
import type { PlayerState } from './types';
|
||||
import { AudioEngine } from './engine';
|
||||
import { PlaybackPersistence } from './persistence';
|
||||
import { DesktopOffline, desktopEventSink, offlineOwner } from '../features/desktop/offline';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
@@ -34,10 +39,17 @@ const PRELOAD_BEFORE_MS = 20_000;
|
||||
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||
*/
|
||||
export class Player {
|
||||
private activity: ActivityPublisher | null = null;
|
||||
private listening: ListeningCoordinator | null = null;
|
||||
private applyingSync = false;
|
||||
private remoteRoom: string | null = null;
|
||||
readonly offline: DesktopOffline | null;
|
||||
private retryEvents: (() => Promise<void>) | null = null;
|
||||
private readonly engine: AudioEngine;
|
||||
private get audio() { return this.engine.audio; }
|
||||
private get overlap() { return this.engine.overlap; }
|
||||
private fading = false;
|
||||
private fadeVersion = 0;
|
||||
private ignoreEnded = false;
|
||||
private readonly queue = new PlayQueue();
|
||||
private readonly events: EventBuffer;
|
||||
@@ -65,8 +77,16 @@ export class Player {
|
||||
audio?: HTMLAudioElement,
|
||||
) {
|
||||
this.engine = new AudioEngine(audio);
|
||||
const owner = offlineOwner(session);
|
||||
this.offline = owner ? new DesktopOffline(owner, session) : null;
|
||||
if (owner) {
|
||||
const send = desktopEventSink(session, owner);
|
||||
this.retryEvents = () => send([]);
|
||||
this.sendEvents = send;
|
||||
}
|
||||
this.events = new EventBuffer({
|
||||
send: async (events) => {
|
||||
if (this.sendEvents) return this.sendEvents(events);
|
||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||
},
|
||||
});
|
||||
@@ -82,9 +102,27 @@ export class Player {
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
start() {
|
||||
this.activity ??= createActivityPublisher(this.session);
|
||||
this.activity.update(this.state);
|
||||
if (!this.listening) {
|
||||
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
|
||||
read: this.getState,
|
||||
pause: () => { this.restoreVersion++; this.leaveWave(); this.cancelCrossfade(); this.audio.pause(); },
|
||||
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||
changed: listening => this.update({ listening }),
|
||||
});
|
||||
this.listening.start();
|
||||
}
|
||||
this.offline?.activate();
|
||||
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
||||
if (this.retryEvents) {
|
||||
void this.retryEvents().catch(() => undefined);
|
||||
this.timers.push(setInterval(() => void this.retryEvents?.().catch(() => undefined), FLUSH_EVERY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
private sendEvents: ((events: import('@pipu/core').PlayEvent[]) => Promise<void>) | null = null;
|
||||
|
||||
// --- подписка (useSyncExternalStore) ---
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
@@ -98,6 +136,8 @@ export class Player {
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.activity?.update(this.state);
|
||||
this.listening?.update();
|
||||
for (const fn of this.listeners) fn();
|
||||
this.media.syncDesktopMedia();
|
||||
}
|
||||
@@ -117,6 +157,7 @@ export class Player {
|
||||
|
||||
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
|
||||
playList(tracks: Track[], index: number, context: PlayContext) {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||
const target = tracks[index];
|
||||
if (!target || !playable.length) return;
|
||||
@@ -133,15 +174,25 @@ export class Player {
|
||||
|
||||
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||
if (this.listening?.locked) return;
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
const version = this.wavePolicy.version;
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/wave/sessions', {
|
||||
let res;
|
||||
try {
|
||||
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', {
|
||||
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
|
||||
}),
|
||||
);
|
||||
}));
|
||||
} catch (error) {
|
||||
if (!this.offline) throw error;
|
||||
await this.offline.ready;
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
const tracks = this.offline.tracks().filter((t) => t.reaction !== 'dislike');
|
||||
if (!tracks.length) throw error;
|
||||
this.playList(tracks.sort(() => Math.random() - 0.5), 0, { type: 'collection', id: 'offline' });
|
||||
return;
|
||||
}
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) {
|
||||
@@ -163,6 +214,7 @@ export class Player {
|
||||
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
|
||||
*/
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
const wave = this.state.wave;
|
||||
if (!wave) return this.startWave(activity, mood);
|
||||
this.update({ wave: { ...wave, activity, mood } });
|
||||
@@ -188,6 +240,7 @@ export class Player {
|
||||
}
|
||||
|
||||
stopWave() {
|
||||
if (this.listening?.locked) return;
|
||||
this.leaveWave();
|
||||
this.pause();
|
||||
}
|
||||
@@ -220,11 +273,13 @@ export class Player {
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
if (this.state.playing) this.pause();
|
||||
else this.play();
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
if (!this.queue.current) return;
|
||||
if (!this.audio.src || !this.attempt) {
|
||||
void this.load(true, this.state.positionMs);
|
||||
@@ -236,10 +291,13 @@ export class Player {
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
this.cancelCrossfade();
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.listening?.locked) return;
|
||||
this.finishAttempt('skip');
|
||||
if (this.queue.next()) {
|
||||
this.syncQueue();
|
||||
@@ -251,6 +309,7 @@ export class Player {
|
||||
|
||||
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
|
||||
prev() {
|
||||
if (this.listening?.locked) return;
|
||||
if (this.audio.currentTime > 3) {
|
||||
this.seek(0);
|
||||
return;
|
||||
@@ -263,6 +322,7 @@ export class Player {
|
||||
}
|
||||
|
||||
jump(index: number) {
|
||||
if (this.listening?.locked) return;
|
||||
this.finishAttempt('skip');
|
||||
this.queue.jump(index);
|
||||
this.syncQueue();
|
||||
@@ -270,9 +330,11 @@ export class Player {
|
||||
}
|
||||
|
||||
seek(ms: number) {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
const track = this.queue.current;
|
||||
if (!track) return;
|
||||
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
||||
this.cancelCrossfade();
|
||||
const from = Math.round(this.audio.currentTime * 1000);
|
||||
this.attempt?.seek(to);
|
||||
this.audio.currentTime = to / 1000;
|
||||
@@ -296,12 +358,14 @@ export class Player {
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
if (this.listening?.locked) return;
|
||||
this.queue.setShuffle(!this.queue.shuffle);
|
||||
this.syncQueue();
|
||||
this.saveLocal();
|
||||
}
|
||||
|
||||
cycleRepeat() {
|
||||
if (this.listening?.locked) return;
|
||||
const order: RepeatMode[] = ['off', 'all', 'one'];
|
||||
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
|
||||
this.syncQueue();
|
||||
@@ -336,15 +400,24 @@ export class Player {
|
||||
|
||||
// --- загрузка трека ---
|
||||
|
||||
private async load(autoplay: boolean, startMs = 0) {
|
||||
private async load(autoplay: boolean, startMs = 0, current = () => true) {
|
||||
this.cancelCrossfade();
|
||||
const track = this.queue.current;
|
||||
if (!track?.stream_url) return;
|
||||
const version = this.restoreVersion;
|
||||
if (this.offline) {
|
||||
await this.offline.ready.catch(() => undefined);
|
||||
if (!current() || this.queue.current !== track || version !== this.restoreVersion) return;
|
||||
}
|
||||
this.offline?.keep([track.id]);
|
||||
const source = this.offline?.local(track).stream_url ?? track.stream_url;
|
||||
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
// Keep the restored element's buffer when the user presses Play.
|
||||
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||
if (this.audio.getAttribute('src') !== source) this.audio.src = source;
|
||||
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||
this.media.updateMediaMetadata(track);
|
||||
if (!autoplay) return;
|
||||
void this.offline?.save(track).catch(() => undefined);
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
this.preloaded = null;
|
||||
@@ -361,6 +434,7 @@ export class Player {
|
||||
try {
|
||||
const resumed = this.engine.resume();
|
||||
if (resumed) await resumed;
|
||||
if (!current() || version !== this.restoreVersion) return;
|
||||
await this.audio.play();
|
||||
} catch {
|
||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||
@@ -414,10 +488,25 @@ export class Player {
|
||||
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
|
||||
}
|
||||
|
||||
/** A cancelled transition must not keep a second source audible or change the new queue. */
|
||||
private cancelCrossfade() {
|
||||
this.fadeVersion++;
|
||||
const wasFading = this.fading;
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
this.overlap.pause();
|
||||
this.overlap.volume = 0;
|
||||
if (this.overlap.hasAttribute('src')) {
|
||||
this.overlap.removeAttribute('src');
|
||||
this.overlap.load();
|
||||
}
|
||||
if (wasFading) this.applyLoudness();
|
||||
}
|
||||
|
||||
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
||||
private maybeCrossfade(positionMs: number) {
|
||||
const cf = this.crossfadeSeconds();
|
||||
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
|
||||
if (this.listening?.locked || this.fading || cf <= 0 || this.state.repeat === 'one') return;
|
||||
const dur = this.audio.duration;
|
||||
if (!Number.isFinite(dur)) return;
|
||||
const left = dur * 1000 - positionMs;
|
||||
@@ -425,14 +514,16 @@ export class Player {
|
||||
const next = this.state.queue[this.state.index + 1];
|
||||
if (!next?.stream_url || !next.available) return;
|
||||
this.fading = true;
|
||||
this.overlap.src = next.stream_url;
|
||||
const version = ++this.fadeVersion;
|
||||
this.offline?.keep([this.queue.current!.id, next.id]);
|
||||
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
|
||||
this.overlap.volume = 0;
|
||||
void this.overlap.play().catch(() => {
|
||||
this.fading = false;
|
||||
if (version === this.fadeVersion) this.cancelCrossfade();
|
||||
});
|
||||
const started = performance.now();
|
||||
const step = () => {
|
||||
if (!this.fading) return;
|
||||
if (!this.fading || version !== this.fadeVersion) return;
|
||||
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
|
||||
this.applyLoudness(1 - p);
|
||||
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
|
||||
@@ -440,12 +531,12 @@ export class Player {
|
||||
requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
this.finishCrossfade(next);
|
||||
this.finishCrossfade(next, version);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
private finishCrossfade(next: Track) {
|
||||
private finishCrossfade(next: Track, version: number) {
|
||||
const at = this.overlap.currentTime;
|
||||
this.ignoreEnded = true;
|
||||
this.audio.pause();
|
||||
@@ -458,7 +549,8 @@ export class Player {
|
||||
return;
|
||||
}
|
||||
this.syncQueue();
|
||||
const src = next.stream_url;
|
||||
const src = this.offline?.local(next).stream_url ?? next.stream_url;
|
||||
this.offline?.keep([next.id]);
|
||||
if (!src) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
@@ -471,7 +563,9 @@ export class Player {
|
||||
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
|
||||
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
|
||||
this.media.updateMediaMetadata(next);
|
||||
void this.offline?.save(next).catch(() => undefined);
|
||||
void this.audio.play().finally(() => {
|
||||
if (version !== this.fadeVersion) return;
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
});
|
||||
@@ -560,7 +654,7 @@ export class Player {
|
||||
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
|
||||
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
|
||||
this.preloaded = next.id;
|
||||
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
||||
void fetch(this.offline?.local(next).stream_url ?? next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
||||
}
|
||||
|
||||
// --- сохранение состояния ---
|
||||
@@ -583,10 +677,18 @@ export class Player {
|
||||
const version = ++this.restoreVersion;
|
||||
const saved = await this.persistence.restore();
|
||||
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||
let items: Track[];
|
||||
try {
|
||||
items = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })).items;
|
||||
} catch (error) {
|
||||
if (!this.offline) throw error;
|
||||
await this.offline.ready;
|
||||
const cached = new Map(this.offline.tracks().map((t) => [t.id, t]));
|
||||
items = saved.queue.flatMap((id) => cached.get(id) ?? []);
|
||||
}
|
||||
if (version !== this.restoreVersion) return;
|
||||
const wanted = saved.queue[saved.index];
|
||||
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||
const tracks = items.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) return;
|
||||
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
|
||||
this.context = saved.context;
|
||||
@@ -603,15 +705,47 @@ export class Player {
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.listening?.dispose();
|
||||
this.listening = null;
|
||||
this.activity?.dispose();
|
||||
this.activity = null;
|
||||
this.restoreVersion++;
|
||||
this.wavePolicy.activate(null);
|
||||
this.fadeVersion++;
|
||||
this.fading = false;
|
||||
this.finishAttempt('skip');
|
||||
this.engine.dispose();
|
||||
this.offline?.dispose();
|
||||
this.persistence.dispose();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
this.media.dispose();
|
||||
void this.events.flush();
|
||||
}
|
||||
|
||||
beginListeningAudio() {
|
||||
if (this.state.listening?.room?.playing) {
|
||||
this.applyingSync = true; try { this.play(); } finally { this.applyingSync = false; }
|
||||
}
|
||||
this.listening?.beginAudio();
|
||||
}
|
||||
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||
if (!room.track_id) { this.cancelCrossfade(); this.audio.pause(); return; }
|
||||
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||
if (!current()) return;
|
||||
this.remoteRoom = room.id;
|
||||
this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); this.context = { type: 'queue' };
|
||||
this.queue.set([track], 0); this.queue.repeat = 'off'; this.queue.shuffle = false; this.syncQueue();
|
||||
await this.load(room.playing, Math.min(position, Math.max(0, track.duration_ms - 100)), current);
|
||||
return;
|
||||
}
|
||||
if (!current()) return;
|
||||
this.applyingSync = true;
|
||||
try {
|
||||
if (Math.abs(this.state.positionMs - position) > 1500) this.seek(Math.min(position, Math.max(0, this.state.durationMs - 100)));
|
||||
if (room.playing && !this.state.playing) this.play();
|
||||
if (!room.playing && this.state.playing) this.pause();
|
||||
} finally { this.applyingSync = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
listening?: import('@pipu/core').ListeningClientState;
|
||||
wave: WaveInfo | null;
|
||||
track: Track | null;
|
||||
queue: Track[];
|
||||
|
||||
@@ -23,6 +23,9 @@ export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/
|
||||
export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.svg';
|
||||
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
||||
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
||||
export { default as arrowBackIcon } from '@material-symbols/svg-400/rounded/arrow_back.svg';
|
||||
export { default as chatIcon } from '@material-symbols/svg-400/rounded/chat_bubble.svg';
|
||||
export { default as smileIcon } from '@material-symbols/svg-400/rounded/sentiment_satisfied.svg';
|
||||
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
||||
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
|
||||
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
|
||||
|
||||
@@ -420,6 +420,21 @@
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.menuBadge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-fill);
|
||||
color: var(--on-accent, #fff);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.menuDanger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -149,21 +149,25 @@ export function Dialog({
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
useEffect(() => {
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
|
||||
(first ?? ref.current)?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') closeRef.current();
|
||||
if (e.key === 'Tab') {
|
||||
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
|
||||
@@ -182,10 +186,10 @@ export function Dialog({
|
||||
document.removeEventListener('keydown', onKey);
|
||||
prev?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
}, []);
|
||||
return createPortal(
|
||||
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
||||
<div ref={ref} className={cx(s.dialog, className)} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
||||
<div className={s.dialogHead}>
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
|
||||
@@ -200,7 +204,7 @@ export function Dialog({
|
||||
);
|
||||
}
|
||||
|
||||
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
|
||||
export type MenuItem = { label: string; icon?: string; danger?: boolean; badge?: number; onSelect: () => void };
|
||||
|
||||
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
|
||||
export function Menu({
|
||||
@@ -210,6 +214,7 @@ export function Menu({
|
||||
header,
|
||||
align = 'end',
|
||||
triggerClassName,
|
||||
portal = false,
|
||||
}: {
|
||||
label: string;
|
||||
trigger: ReactNode;
|
||||
@@ -217,8 +222,10 @@ export function Menu({
|
||||
header?: ReactNode;
|
||||
align?: 'start' | 'end';
|
||||
triggerClassName?: string;
|
||||
portal?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState<CSSProperties>();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -246,7 +253,7 @@ export function Menu({
|
||||
els[next]?.focus();
|
||||
};
|
||||
const menu = (
|
||||
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||
<div ref={menuRef} style={portal && !window.matchMedia?.('(max-width: 767px)').matches ? position : undefined} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||
{header}
|
||||
{items.map((it) => (
|
||||
<button
|
||||
@@ -261,6 +268,7 @@ export function Menu({
|
||||
>
|
||||
{it.icon && <Icon src={it.icon} size={18} />}
|
||||
{it.label}
|
||||
{it.badge ? <span className={s.menuBadge}>{it.badge > 99 ? '99+' : it.badge}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -273,11 +281,18 @@ export function Menu({
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={e => {
|
||||
if (portal) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const bottom = Math.max(12, window.innerHeight - rect.top + 6);
|
||||
setPosition({ position: 'fixed', zIndex: 60, top: 'auto', bottom, right: Math.max(12, window.innerWidth - rect.right), maxHeight: Math.max(120, window.innerHeight - bottom - 12), overflowY: 'auto' });
|
||||
}
|
||||
setOpen(v => !v);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
{open && (window.matchMedia?.('(max-width: 767px)').matches
|
||||
{open && (portal || window.matchMedia?.('(max-width: 767px)').matches
|
||||
? createPortal(menu, document.body)
|
||||
: menu)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fakeApi, makeUser } from './fakeApi';
|
||||
import { albumTracks, fakeApi, makeUser } from './fakeApi';
|
||||
import { mobileFixtures } from './mobileFixtures';
|
||||
|
||||
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
|
||||
@@ -6,9 +6,37 @@ export function installE2EFetch() {
|
||||
const api = fakeApi([
|
||||
makeUser('admin', 'admin', { must_change_credentials: true }),
|
||||
makeUser('alice', 'user'),
|
||||
makeUser('update-manager', 'user', { manages_updates: true }),
|
||||
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
|
||||
makeUser('moderator', 'moderator', { reviews_uploads: true }),
|
||||
]);
|
||||
// A ready conversation lets browser tests exercise messages and music sharing.
|
||||
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
|
||||
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
|
||||
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
|
||||
const listeningScenario = localStorage.getItem('pipu.e2e.listening');
|
||||
if (listeningScenario) {
|
||||
const device = crypto.randomUUID();
|
||||
api.presence.set(friend.id, Date.now());
|
||||
api.activityVisibility.set(friend.id, 'friends');
|
||||
api.activity.set(friend.id, { device_id: device, track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
|
||||
api.listeningPreferences.set(friend.id, { requests: listeningScenario === 'restricted' ? 'nobody' : 'friends', auto_accept: listeningScenario === 'automatic' ? 'friends' : 'nobody', autoplay: true });
|
||||
if (listeningScenario === 'incoming') {
|
||||
const roomId = crypto.randomUUID();
|
||||
const requestId = crypto.randomUUID();
|
||||
api.listeningRooms.set(roomId, { id: roomId, owner_id: friend.id, owner_name: friend.username, device_id: device, track_id: albumTracks[0]!.id, position_ms: 0, playing: false, updated_at: new Date().toISOString(), participants: [] });
|
||||
api.listeningMembers.set(friend.id, roomId);
|
||||
api.listeningRequests.set(requestId, { id: requestId, owner_id: friend.id, guest_id: alice.id, sender_id: friend.id, sender_name: friend.username, recipient_id: alice.id, kind: 'invite', expires_at: new Date(Date.now() + 120_000).toISOString() });
|
||||
}
|
||||
}
|
||||
if (localStorage.getItem('pipu.e2e.notifications') === '1') {
|
||||
const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const;
|
||||
for (let index = 0; index < 28; index++) api.notifications.push({
|
||||
id: `event-${index}`, recipient: alice.id, actor_id: friend.id,
|
||||
actor_name: index % 3 === 0 ? 'JoyRacoon' : index % 3 === 1 ? 'Александра' : 'Константин',
|
||||
kind: kinds[index % kinds.length]!, created_at: new Date(Date.now() - index * 36e5).toISOString(), read: index >= 4,
|
||||
});
|
||||
}
|
||||
const orig = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
|
||||
@@ -78,13 +78,58 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||
const playlistOwners = new Map<string, string>();
|
||||
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||
const friendships = new Map<string, { sender: string; accepted: boolean }>();
|
||||
const blocks = new Set<string>();
|
||||
const messages: (components['schemas']['DirectMessage'] & { recipient: string })[] = [];
|
||||
const notifications: (components['schemas']['SocialNotification'] & { recipient: string })[] = [];
|
||||
const activityVisibility = new Map<string, components['schemas']['ActivityVisibility']>();
|
||||
const friendRecommendations = new Map<string, boolean>();
|
||||
const activity = new Map<string, components['schemas']['ListeningActivity'] & { device_id: string; position_ms: number }>();
|
||||
const listeningPreferences = new Map<string, components['schemas']['ListeningPreferences']>();
|
||||
const listeningRooms = new Map<string, components['schemas']['ListeningRoom']>();
|
||||
const listeningMembers = new Map<string, string>();
|
||||
const listeningRequests = new Map<string, components['schemas']['ListeningRequest'] & { guest_id: string }>();
|
||||
const typing = new Map<string, number>();
|
||||
const presence = new Map<string, number>();
|
||||
const defaultListening = { requests: 'nobody', auto_accept: 'nobody', autoplay: true } as const;
|
||||
const pair = (a: string, b: string) => [a, b].sort().join(':');
|
||||
const isBlocked = (a: string, b: string) => blocks.has(`${a}:${b}`) || blocks.has(`${b}:${a}`);
|
||||
const socialPerson = (me: string, p: FakeUser): components['schemas']['SocialPerson'] => {
|
||||
const f = friendships.get(pair(me, p.id));
|
||||
const last = f?.accepted && !isBlocked(me, p.id) ? messages.filter(m => pair(m.sender_id, m.recipient) === pair(me, p.id)).at(-1) : undefined;
|
||||
const visible = activityVisibility.get(p.id) === 'everyone' || activityVisibility.get(p.id) === 'friends' && f?.accepted;
|
||||
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username, ...(visible && activity.has(p.id) && !isBlocked(me,p.id) ? { activity: activity.get(p.id) } : {}),
|
||||
...(last ? { last_message: { id: last.id, sender_id: last.sender_id, text: last.deleted ? '' : last.text.slice(0, 160), attachment_kind: last.attachment_kind, created_at: last.created_at, deleted: last.deleted ?? false } } : {}),
|
||||
relationship: blocks.has(`${me}:${p.id}`) ? 'blocked' : f?.accepted ? 'friend' : f ? f.sender === me ? 'outgoing' : 'incoming' : 'none',
|
||||
online: !!f?.accepted && !isBlocked(me, p.id) && (presence.get(p.id) ?? 0) > Date.now() - 45_000,
|
||||
unread: messages.filter(m => m.recipient === me && m.sender_id === p.id && !m.read).length };
|
||||
};
|
||||
const notify = (recipient: string, actor: FakeUser, kind: components['schemas']['SocialNotification']['kind']) => notifications.unshift({
|
||||
id: crypto.randomUUID(), recipient, actor_id: actor.id, actor_name: actor.display_name ?? actor.username, kind, read: false, created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
let managementSources = [{ id: 'origin', name: 'Текущий репозиторий', url: 'https://git.example.org/pipu/music.git' }];
|
||||
let managementJob: components['schemas']['ServerJob'] | null = null;
|
||||
let configRevision = 1;
|
||||
let configPending = false;
|
||||
const configValues: Record<string, string> = {};
|
||||
const configEntries: components['schemas']['ServerConfigEntry'][] = [
|
||||
{ key: 'LOG_LEVEL', group: 'shared', type: 'string', secret: false, readonly: false, effective: 'info', env: 'info', local: null, default_value: 'info', source: 'env' },
|
||||
{ key: 'TRAIN_HOUR', group: 'recommender', type: 'integer', secret: false, readonly: false, effective: '3', env: null, local: null, default_value: '3', source: 'default' },
|
||||
{ key: 'GITEA_TOKEN', group: 'api', type: 'string', secret: true, readonly: false, effective: '••••••••', env: null, local: '••••••••', default_value: '', source: 'local' },
|
||||
];
|
||||
const serverConfig = () => ({ revision: String(configRevision), restart_required: configPending, entries: configEntries.map((entry) => {
|
||||
const rawLocal = configValues[entry.key] ?? entry.local;
|
||||
const local = entry.secret && rawLocal ? '••••••••' : rawLocal;
|
||||
return { ...entry, local, effective: entry.env ?? local ?? entry.default_value, source: entry.env !== null ? 'env' : local !== null ? 'local' : 'default' };
|
||||
}) });
|
||||
const me = (u: FakeUser): Me => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
display_name: u.display_name,
|
||||
role: u.role,
|
||||
reviews_uploads: u.reviews_uploads,
|
||||
manages_updates: u.role === 'admin' || !!u.manages_updates,
|
||||
locale: u.locale,
|
||||
ui_prefs: prefs.get(u.id) ?? {},
|
||||
must_change_password: u.must_change_password,
|
||||
@@ -107,6 +152,33 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
calls.push({ method: req.method, path, body });
|
||||
const route = `${req.method} ${path}`;
|
||||
const user = current ? users.get(current) : undefined;
|
||||
if (path.startsWith('/api/v1/admin/server/')) {
|
||||
if (!user) return problem(401, 'auth.unauthorized');
|
||||
const configs = path.includes('/config');
|
||||
if (configs ? user.role !== 'admin' : user.role !== 'admin' && !user.manages_updates) return problem(403, 'auth.forbidden');
|
||||
if (route === 'GET /api/v1/admin/server/updates') {
|
||||
const channel = url.searchParams.get('channel') ?? 'latest';
|
||||
const selected = url.searchParams.get('version') ?? '';
|
||||
const target = channel === 'version' ? selected : channel === 'main' ? 'main' : 'v1.10.0';
|
||||
return json({ sources: managementSources, versions: ['v1.10.0', 'v1.9.0', 'v2.0.0-rc.1'], current_version: '1.9.0', source_error: '', job: managementJob,
|
||||
current_commit: 'a'.repeat(40), target_version: target, target_commit: target === 'v1.9.0' || target === 'main' ? 'a'.repeat(40) : 'b'.repeat(40),
|
||||
update_state: !target ? 'select_version' : target === 'v1.9.0' || target === 'main' ? 'current' : 'available' });
|
||||
}
|
||||
if (route === 'PUT /api/v1/admin/server/update-sources') { managementSources = body?.sources as typeof managementSources; return json({ sources: managementSources }); }
|
||||
if (route === 'GET /api/v1/admin/server/config') return json(serverConfig());
|
||||
if (route === 'PATCH /api/v1/admin/server/config') {
|
||||
if (body?.revision !== String(configRevision)) return problem(409, 'management.config_conflict');
|
||||
for (const [key, value] of Object.entries(body?.values as Record<string, string | null>)) {
|
||||
if (value === null) delete configValues[key]; else configValues[key] = value;
|
||||
}
|
||||
configRevision++; configPending = true; return json(serverConfig());
|
||||
}
|
||||
if (route === 'POST /api/v1/admin/server/updates' || route === 'POST /api/v1/admin/server/config/apply') {
|
||||
managementJob = { id: 'job-1', kind: configs ? 'config' : 'update', state: 'running', target: configs ? 'config.local' : body?.channel === 'version' ? String(body.version) : body?.channel === 'main' ? 'main' : 'v1.10.0', started_at: '2026-10-22T09:00:00Z', finished_at: '', log: '→ build and restart' };
|
||||
if (configs) configPending = false;
|
||||
return json(managementJob, 202);
|
||||
}
|
||||
}
|
||||
|
||||
switch (route) {
|
||||
case 'GET /api/v1/server-info':
|
||||
@@ -158,6 +230,100 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
|
||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||
|
||||
if (path === '/api/v1/me/social/activity/visibility' && body) { activityVisibility.set(user.id, body.visibility as components['schemas']['ActivityVisibility']); if (body.visibility === 'nobody') activity.delete(user.id); return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/recommendations' && body) { friendRecommendations.set(user.id,!!body.enabled);return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/activity' && body) {
|
||||
if (activityVisibility.get(user.id) !== undefined && activityVisibility.get(user.id) !== 'nobody' && body.playing) {
|
||||
const track = albumTracks.find(t => t.id === body.track_id); const playlist = playlists.get(String(body.playlist_id));
|
||||
if (track) activity.set(user.id,{track_id:track.id,track_title:track.title,artist:'Кино',context: body.context as 'wave'|'playlist'|'other',device_id:String(body.device_id),position_ms:Number(body.position_ms??0),...(playlist ? {playlist_id:playlist.id,playlist_name:playlist.name} : {})});
|
||||
} else activity.delete(user.id);
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
const activityPlaylistPath=/^\/api\/v1\/social\/activity\/([^/]+)\/playlist$/.exec(path);
|
||||
if (activityPlaylistPath) {
|
||||
const owner=users.get(activityPlaylistPath[1]!); const a=owner ? socialPerson(user.id,owner).activity : undefined; const p=a?.playlist_id ? playlists.get(a.playlist_id) : undefined;
|
||||
return p ? json({...p,share_id:null}) : problem(404,'social.activity_unavailable');
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/preferences' && body) { listeningPreferences.set(user.id,body as components['schemas']['ListeningPreferences']);return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/listening') {
|
||||
presence.set(user.id, Date.now());
|
||||
const room=listeningRooms.get(listeningMembers.get(user.id) ?? '');
|
||||
if(room)room.participants=[...listeningMembers].filter(([,id])=>id===room.id).map(([id])=>({id,name:users.get(id)?.display_name??users.get(id)?.username??id}));
|
||||
return json({preferences:listeningPreferences.get(user.id)??defaultListening,room:room??null,requests:[...listeningRequests.values()].filter(r=>r.recipient_id===user.id||r.sender_id===user.id),server_time:new Date().toISOString()});
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/state' && body) {
|
||||
const room=listeningRooms.get(String(body.room_id));if(!room||room.owner_id!==user.id||room.device_id!==body.device_id)return problem(403,'social.unavailable');
|
||||
Object.assign(room,{track_id:body.track_id??null,position_ms:body.position_ms,playing:body.playing,updated_at:new Date().toISOString()});return new Response(null,{status:204});
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/actions' && body) {
|
||||
const target=String(body.target_id);const op=String(body.action);
|
||||
if(op==='join'||op==='invite'){
|
||||
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
|
||||
const friend=!!friendships.get(pair(user.id,target))?.accepted;
|
||||
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.listening_unavailable');
|
||||
if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline');
|
||||
const guestRoom = listeningRooms.get(listeningMembers.get(guest) ?? '');
|
||||
const hostRoom = listeningRooms.get(listeningMembers.get(host) ?? '');
|
||||
if (guestRoom && guestRoom.owner_id !== host || hostRoom && hostRoom.owner_id !== host) return problem(409, 'social.listening_busy');
|
||||
if (guestRoom?.owner_id === host || listeningRequests.has(String(body.id))) return new Response(null, { status: 204 });
|
||||
if ([...listeningRequests.values()].some(r => r.owner_id === host && r.guest_id === guest)) return problem(409, 'social.listening_pending');
|
||||
let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
|
||||
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
|
||||
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
|
||||
else listeningRequests.set(String(body.id),{id:String(body.id),owner_id:host,guest_id:guest,sender_id:user.id,sender_name:user.username,recipient_id:target,kind:op,expires_at:new Date(Date.now()+120000).toISOString()});
|
||||
}else if(op==='accept'||op==='decline'||op==='cancel'){
|
||||
const r=listeningRequests.get(target);if(r){if(op==='accept'&&r.recipient_id===user.id){const room=[...listeningRooms.values()].find(room=>room.owner_id===r.owner_id);if(room)listeningMembers.set(r.guest_id,room.id);}listeningRequests.delete(target);}
|
||||
}else if(op==='leave'||op==='end'){
|
||||
const room=listeningRooms.get(target);if(room?.owner_id===user.id){listeningRooms.delete(target);for(const [u,r]of listeningMembers)if(r===target)listeningMembers.delete(u);}else listeningMembers.delete(user.id);
|
||||
}
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
const chatMeta=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/meta$/.exec(path);
|
||||
if(chatMeta){const peer=chatMeta[1]!;if(!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');if(req.method==='PUT'){typing.set(`${user.id}:${peer}`,body?.typing?Date.now()+6000:0);return new Response(null,{status:204});}return json({typing:(typing.get(`${peer}:${user.id}`)??0)>Date.now()});}
|
||||
const updateMessage=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/messages\/([^/]+)$/.exec(path);
|
||||
if(updateMessage&&body){
|
||||
const peer=updateMessage[1]!;const m=messages.find(m=>m.id===updateMessage[2]&&pair(m.sender_id,m.recipient)===pair(user.id,peer));
|
||||
if(!m||!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');
|
||||
if(body.action!=='react'&&m.sender_id!==user.id)return problem(403,'social.unavailable');
|
||||
if(body.action==='edit'){m.text=String(body.text);m.edited_at=new Date().toISOString();}
|
||||
if(body.action==='delete'){m.deleted=true;m.text='';m.attachment_id=undefined;m.attachment_kind='none';m.reactions=[];}
|
||||
if(body.action==='react'){const reactions=m.reactions??[];const old=reactions.find(r=>r.emoji===body.emoji);m.reactions=old?reactions.filter(r=>r!==old):[...reactions,{emoji:String(body.emoji),count:1,mine:true}];}
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/me/social') return json({
|
||||
activity_visibility: activityVisibility.get(user.id) ?? 'nobody',
|
||||
friends_recommendations: friendRecommendations.get(user.id) ?? true,
|
||||
people: [...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !blocks.has(`${p.id}:${user.id}`)).map(p => socialPerson(user.id, p)).filter(p => p.relationship !== 'none'),
|
||||
notifications: notifications.filter(n => n.recipient === user.id && !isBlocked(user.id, n.actor_id)),
|
||||
unread: notifications.filter(n => n.recipient === user.id && !n.read && !isBlocked(user.id, n.actor_id)).length,
|
||||
});
|
||||
if (path === '/api/v1/me/social/people') {
|
||||
const q = url.searchParams.get('q')?.toLowerCase() ?? '';
|
||||
return json([...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !isBlocked(user.id,p.id) && `${p.username} ${p.display_name ?? ''}`.toLowerCase().includes(q)).map(p => socialPerson(user.id,p)));
|
||||
}
|
||||
if (path === '/api/v1/me/social/actions' && body) {
|
||||
const peer = String(body.target_id); const action = body.action; const p = users.get(peer); const f = friendships.get(pair(user.id,peer));
|
||||
if (action === 'request' && p && !isBlocked(user.id,peer) && !f) { friendships.set(pair(user.id,peer),{ sender:user.id,accepted:false }); notify(peer,user,'friend_request'); }
|
||||
if (action === 'accept' && f && f.sender !== user.id && !f.accepted) { f.accepted=true; notify(peer,user,'friend_accepted'); }
|
||||
if (action === 'remove' || action === 'block') friendships.delete(pair(user.id,peer));
|
||||
if (action === 'block') blocks.add(`${user.id}:${peer}`);
|
||||
if (action === 'unblock') blocks.delete(`${user.id}:${peer}`);
|
||||
if (action === 'read_notification') notifications.filter(n => n.recipient === user.id && n.id === peer).forEach(n => { n.read=true; });
|
||||
if (action === 'read_chat') { messages.filter(m => m.recipient === user.id && m.sender_id === peer && m.seq <= Number(body.up_to_seq)).forEach(m => { m.read=true; }); notifications.filter(n => n.recipient === user.id && n.actor_id === peer).forEach(n => { n.read=true; }); }
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
const chatPath = /^\/api\/v1\/me\/social\/chats\/([^/]+)$/.exec(path);
|
||||
if (chatPath) {
|
||||
const peer=chatPath[1]!;if (!friendships.get(pair(user.id,peer))?.accepted || isBlocked(user.id,peer)) return problem(403,'social.unavailable');
|
||||
if (req.method === 'GET') {const q=url.searchParams.get('q')?.toLowerCase()??'';return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before'))) && (!q||!m.deleted&&m.text.toLowerCase().includes(q)) && (url.searchParams.get('music') !== 'true' || !m.deleted && m.attachment_kind !== 'none')).slice(-50).map(m=>({...m,reply_preview:m.reply_to?messages.find(r=>r.id===m.reply_to)?.text:undefined})));}
|
||||
if (req.method === 'POST' && body) {
|
||||
const old=messages.find(m => m.id===body.id);if(old)return json(old);
|
||||
const message={...body,recipient:peer,sender_id:user.id,seq:messages.length+1,created_at:new Date().toISOString(),read:false} as typeof messages[number];
|
||||
messages.push(message);notify(peer,user,body.attachment_kind==='none'?'message':'music');return json(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
|
||||
if (sharedPath && req.method === 'GET') {
|
||||
@@ -323,6 +489,10 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
|
||||
target.username = username;
|
||||
}
|
||||
if (typeof body?.manages_updates === 'boolean') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
target.manages_updates = body.manages_updates;
|
||||
}
|
||||
if (typeof body?.reviews_uploads === 'boolean') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
target.reviews_uploads = body.reviews_uploads;
|
||||
@@ -349,5 +519,5 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return problem(404, 'common.not_found');
|
||||
};
|
||||
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
|
||||
return { presence, fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, activityVisibility, activity, friendRecommendations, listeningPreferences, listeningRooms, listeningMembers, listeningRequests, signOut: () => (current = null) };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const file: components['schemas']['UploadFile'] = {
|
||||
export const mobileFixtures: Record<string, unknown> = {
|
||||
'/api/v1/admin/sources': { items: [source] },
|
||||
[`/api/v1/admin/sources/${source.id}`]: source,
|
||||
[`/api/v1/admin/sources/${source.id}/cover-stats`]: { files_total: 120, files_with_cover: 87, albums_pending: 3 },
|
||||
[`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] },
|
||||
'/api/v1/admin/uploads': { items: [file], next_cursor: null },
|
||||
'/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null,
|
||||
|
||||
@@ -170,6 +170,22 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navSubsection {
|
||||
min-height: 40px;
|
||||
margin: 2px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.navSubgroup > .navItem {
|
||||
margin-left: 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -257,6 +273,11 @@
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Communication owns its scroll areas so its composer stays above either player. */
|
||||
.socialContent { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.socialContent > :not(.routeIn) { flex-shrink: 0; }
|
||||
.socialContent .routeIn { display: flex; flex: 1; min-height: 0; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.shell {
|
||||
--side-w: 72px;
|
||||
@@ -290,6 +311,9 @@
|
||||
.navGroupFolded {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.navSubsection { min-height: 0; }
|
||||
.navSubgroup > .navItem { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
}
|
||||
|
||||
.mobileBottom { display: none; }
|
||||
@@ -298,7 +322,7 @@
|
||||
.shell, .shell.island {
|
||||
--side-w: 0px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr) 72px calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
grid-template-rows: auto minmax(0, 1fr) auto calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
grid-template-areas: 'top' 'content' 'player' 'mobile';
|
||||
}
|
||||
|
||||
@@ -353,6 +377,8 @@
|
||||
border: 0; pointer-events: none;
|
||||
}
|
||||
.mobileMenu .navSectionTitle { display: block; }
|
||||
.mobileMenu .navSubsection { color: var(--text2); font-size: 13px; }
|
||||
.mobileMenu .navSubgroup > .navItem { margin-left: 12px; border-left: 1px solid var(--line); }
|
||||
.mobileMenu .navChevron { display: none; }
|
||||
.content, .island .content { padding: 20px 16px 24px; }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { queueIcon } from '../shared/ui/icons';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useId, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
@@ -37,8 +37,13 @@ import { Ambient } from './Ambient';
|
||||
import { PlayerBar } from './PlayerBar';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
|
||||
import { useSocialUnread, useSocialProfileMenu, SocialProvider } from '../features/social/Social';
|
||||
import { MiniSocial } from '../features/social/MiniSocial';
|
||||
import { ListeningBanner } from '../features/social/Listening';
|
||||
|
||||
type NavPath =
|
||||
| '/friends'
|
||||
| '/'
|
||||
| '/admin/users'
|
||||
| '/admin/audit'
|
||||
@@ -47,8 +52,11 @@ type NavPath =
|
||||
| '/admin/uploads'
|
||||
| '/uploads/new'
|
||||
| '/uploads/mine'
|
||||
| '/admin/updates'
|
||||
| '/admin/config'
|
||||
| '/admin/settings'
|
||||
| '/collection/playlists'
|
||||
| '/collection/downloads'
|
||||
| '/collection/history'
|
||||
| '/collection/likes'
|
||||
| '/collection/dislikes';
|
||||
@@ -102,6 +110,8 @@ function NavSection({
|
||||
children,
|
||||
defaultFolded = false,
|
||||
mobile = false,
|
||||
nested = false,
|
||||
paths,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -109,12 +119,21 @@ function NavSection({
|
||||
children: ReactNode;
|
||||
defaultFolded?: boolean;
|
||||
mobile?: boolean;
|
||||
nested?: boolean;
|
||||
paths?: readonly string[];
|
||||
}) {
|
||||
const groupId = useId();
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const active = paths?.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ?? false;
|
||||
const [folded, setFolded] = useState(() => {
|
||||
if (active) return false;
|
||||
if (readIds(FOLDED_KEY).includes(id)) return true;
|
||||
if (readIds(TOUCHED_KEY).includes(id)) return false;
|
||||
return defaultFolded;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (active) setFolded(false);
|
||||
}, [active, pathname]);
|
||||
const toggle = () => {
|
||||
const next = !folded;
|
||||
setFolded(next);
|
||||
@@ -130,15 +149,15 @@ function NavSection({
|
||||
return (
|
||||
<>
|
||||
{mobile ? (
|
||||
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
|
||||
<h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3>
|
||||
) : (
|
||||
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}>
|
||||
<button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}>
|
||||
<span className={s.navSectionTitle}>{title}</span>
|
||||
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
||||
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
|
||||
</button>
|
||||
)}
|
||||
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
|
||||
<div id={groupId} role="group" aria-label={title} className={cx(s.navGroup, nested && s.navSubgroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -146,15 +165,18 @@ function NavSection({
|
||||
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
||||
export function AppShell() {
|
||||
return useAuthState().status === 'signed_in' ? (
|
||||
<PlayerProvider>
|
||||
<SocialProvider><PlayerProvider>
|
||||
<DesktopUpdateDialog />
|
||||
<Shell />
|
||||
</PlayerProvider>
|
||||
</PlayerProvider></SocialProvider>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const { t } = useTranslation();
|
||||
const user = useUser();
|
||||
const socialUnread = useSocialUnread();
|
||||
const socialProfile = useSocialProfileMenu();
|
||||
const session = useSession();
|
||||
const desktop = useDesktop();
|
||||
const navigate = useNavigate();
|
||||
@@ -167,13 +189,16 @@ function Shell() {
|
||||
const reviews = user.reviews_uploads;
|
||||
const pending = usePendingUploads(reviews);
|
||||
const unseen = useUnseenUploads(staff);
|
||||
const uploadBadge = (reviews ? pending ?? 0 : 0) + (unseen ?? 0);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
const navigation = (mobile = false) => (
|
||||
<>
|
||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||
<NavLink to="/friends" label={t('social.title')} icon={groupIcon} activeIcon={groupFillIcon} badge={socialUnread} />
|
||||
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
||||
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
|
||||
{desktop && <NavLink to="/collection/downloads" label={t('desktop.offline')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
||||
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||
<NavLink
|
||||
@@ -193,29 +218,40 @@ function Shell() {
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
||||
{staff && (
|
||||
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
|
||||
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={uploadBadge} paths={['/admin', '/uploads']} defaultFolded>
|
||||
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||
)}
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
|
||||
{reviews && (
|
||||
<NavLink
|
||||
to="/admin/uploads"
|
||||
label={t('nav.upload_queue')}
|
||||
icon={inboxIcon}
|
||||
activeIcon={inboxFillIcon}
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
</>
|
||||
<NavSection mobile={mobile} nested id="admin-media" title={t('nav.admin_media')} paths={['/admin/library', '/admin/sources']} defaultFolded>
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||
)}
|
||||
</NavSection>
|
||||
<NavSection mobile={mobile} nested id="admin-uploads" title={t('nav.admin_uploads')} badge={uploadBadge} paths={['/uploads', '/admin/uploads']} defaultFolded>
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
|
||||
{reviews && (
|
||||
<NavLink
|
||||
to="/admin/uploads"
|
||||
label={t('nav.upload_queue')}
|
||||
icon={inboxIcon}
|
||||
activeIcon={inboxFillIcon}
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
</NavSection>
|
||||
{(user.role === 'admin' || user.manages_updates) && (
|
||||
<NavSection mobile={mobile} nested id="admin-system" title={t('nav.admin_system')} paths={['/admin/updates', '/admin/config', '/admin/settings', '/admin/audit']} defaultFolded>
|
||||
<NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
</>
|
||||
)}
|
||||
</NavSection>
|
||||
)}
|
||||
</NavSection>
|
||||
)}
|
||||
@@ -256,6 +292,7 @@ function Shell() {
|
||||
</div>
|
||||
}
|
||||
items={[
|
||||
...socialProfile.items,
|
||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||
...(!desktop
|
||||
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
||||
@@ -287,7 +324,9 @@ function Shell() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{socialProfile.dialogs}
|
||||
<ShellMain />
|
||||
<MiniSocial />
|
||||
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
</div>
|
||||
@@ -297,7 +336,9 @@ function Shell() {
|
||||
function ShellMain() {
|
||||
const key = useRouterState({ select: (st) => st.location.pathname });
|
||||
return (
|
||||
<main id="content" className={s.content} tabIndex={-1}>
|
||||
<main id="content" className={`${s.content} ${key === '/friends' ? s.socialContent : ''}`} tabIndex={-1}>
|
||||
<DesktopOfflineBanner />
|
||||
<ListeningBanner />
|
||||
<div key={key} className={s.routeIn}>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -259,7 +259,28 @@ a.source:hover {
|
||||
}
|
||||
|
||||
.play[aria-busy='true'] {
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.playSpinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: playSpin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes playSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.playSpinner {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
@@ -413,28 +434,29 @@ a.source:hover {
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 0 8px; }
|
||||
.bar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas: 'now now' 'controls actions';
|
||||
gap: 4px 8px;
|
||||
min-height: 72px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto;
|
||||
transform: none; margin: 0 8px; border-radius: 16px; }
|
||||
.empty { grid-column: 1 / -1; }
|
||||
.empty { grid-column: 1 / -1; grid-row: 1 / -1; }
|
||||
.placeholder { flex: none; }
|
||||
.now { gap: 8px; }
|
||||
.now { grid-area: now; gap: 10px; }
|
||||
.center { grid-area: controls; justify-self: start; }
|
||||
.now > :first-child { width: 44px !important; height: 44px !important; }
|
||||
.source { display: none; }
|
||||
.controls { gap: 2px; }
|
||||
.play, .icon { width: 44px; height: 44px; }
|
||||
.right { display: flex; gap: 0; }
|
||||
.right { grid-area: actions; display: flex; gap: 0; }
|
||||
.right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; }
|
||||
.error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
|
||||
padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
|
||||
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.now > :first-child { display: none; }
|
||||
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
|
||||
.shareButton { display: none; }
|
||||
.drawer, .drawerIsland { bottom: calc(184px + env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(100dvh - 220px - env(safe-area-inset-bottom, 0px)); }
|
||||
}
|
||||
|
||||
.shareStatus {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { shareTrack } from '../shared/lib/share';
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
import { desktopHandle } from '../desktop';
|
||||
import { useMusicShare } from '../features/social/Social';
|
||||
import {
|
||||
albumIcon,
|
||||
closeIcon,
|
||||
@@ -36,20 +38,12 @@ import s from './PlayerBar.module.css';
|
||||
export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const openShare = useMusicShare();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const narrow = useNarrow();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
useEffect(() => {
|
||||
setShareStatus(null);
|
||||
}, [st.track?.id]);
|
||||
useEffect(() => {
|
||||
if (!shareStatus) return;
|
||||
const timer = setTimeout(() => setShareStatus(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [shareStatus]);
|
||||
const queueButton = useRef<HTMLButtonElement>(null);
|
||||
const queueId = useId();
|
||||
const closeQueue = useCallback(() => {
|
||||
@@ -72,17 +66,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
}
|
||||
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const share = async () => {
|
||||
setSharing(true);
|
||||
setShareStatus(null);
|
||||
try {
|
||||
const result = await shareTrack(track.id, track.title);
|
||||
if (result === 'copied') setShareStatus('copied');
|
||||
} catch {
|
||||
setShareStatus('error');
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
const share = () => {
|
||||
openShare({ attachment: { kind: 'track', id: track.id, title: track.title }, url: trackShareUrl(desktopHandle()?.server ?? window.location.origin, track.id) });
|
||||
};
|
||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||
const ctx = st.context;
|
||||
@@ -111,16 +96,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
>
|
||||
{narrow ? (
|
||||
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||
<Cover album={track.album} size={44} className={st.playing ? s.coverLive : undefined} />
|
||||
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
||||
</button>
|
||||
) : (
|
||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||
)}
|
||||
<div className={s.meta}>
|
||||
<span className={s.title}>{track.title}</span>
|
||||
<span className={s.title} title={track.title}>{track.title}</span>
|
||||
<span className={s.artists}>
|
||||
<ArtistLinks artists={track.artists} />
|
||||
<ArtistLinks artists={track.artists} hideUrlPreview />
|
||||
</span>
|
||||
{source &&
|
||||
(ctx?.type === 'album' && ctx.id ? (
|
||||
@@ -148,7 +133,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
@@ -156,11 +141,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={s.play}
|
||||
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||||
aria-busy={st.loading || undefined}
|
||||
onClick={() => player.toggle()}
|
||||
disabled={locked} onClick={() => player.toggle()}
|
||||
>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||||
{st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={28} />}
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
@@ -184,7 +169,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
aria-valuetext={formatDuration(st.positionMs)}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
@@ -198,7 +183,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
|
||||
<div className={s.right}>
|
||||
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
|
||||
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} onClick={() => void share()}>
|
||||
<Icon src={shareIcon} size={20} />
|
||||
</button>
|
||||
<button
|
||||
@@ -206,7 +191,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={cx(s.icon, st.shuffle && s.accent)}
|
||||
aria-label={t('player.shuffle')}
|
||||
aria-pressed={st.shuffle}
|
||||
onClick={() => player.toggleShuffle()}
|
||||
disabled={locked} onClick={() => player.toggleShuffle()}
|
||||
>
|
||||
<Icon src={shuffleIcon} size={20} />
|
||||
</button>
|
||||
@@ -215,7 +200,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||||
aria-label={repeatLabel}
|
||||
title={repeatLabel}
|
||||
onClick={() => player.cycleRepeat()}
|
||||
disabled={locked} onClick={() => player.cycleRepeat()}
|
||||
>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||
</button>
|
||||
@@ -255,11 +240,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
variant={variant}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
share={share}
|
||||
sharing={sharing}
|
||||
/>
|
||||
)}
|
||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -281,16 +264,15 @@ function NowPlayingSheet({
|
||||
variant,
|
||||
onClose,
|
||||
share,
|
||||
sharing,
|
||||
}: {
|
||||
variant: 'bar' | 'island';
|
||||
onClose: () => void;
|
||||
share: () => Promise<void>;
|
||||
sharing: boolean;
|
||||
share: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const track = st.track;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -328,7 +310,7 @@ function NowPlayingSheet({
|
||||
{track.title}
|
||||
</Link>
|
||||
<span className={s.artists}>
|
||||
<ArtistLinks artists={track.artists} />
|
||||
<ArtistLinks artists={track.artists} hideUrlPreview />
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.progress}>
|
||||
@@ -341,7 +323,7 @@ function NowPlayingSheet({
|
||||
step={1000}
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
@@ -350,13 +332,13 @@ function NowPlayingSheet({
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} aria-busy={st.loading || undefined} disabled={locked} onClick={() => player.toggle()}>
|
||||
{st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={32} />}
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
|
||||
@@ -364,13 +346,13 @@ function NowPlayingSheet({
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.sheetExtra}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} disabled={locked} onClick={() => player.toggleShuffle()}>
|
||||
<Icon src={shuffleIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} disabled={locked} onClick={() => player.cycleRepeat()}>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.share')} onClick={() => void share()}>
|
||||
<Icon src={shareIcon} size={20} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
|
||||
@@ -399,6 +381,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const upcoming = st.queue.slice(st.index, st.index + 40);
|
||||
const close = useRef<HTMLButtonElement>(null);
|
||||
// Клавиатура: при открытии фокус в панель, Escape закрывает.
|
||||
@@ -428,7 +411,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
type="button"
|
||||
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
||||
aria-current={k === 0 || undefined}
|
||||
onClick={() => player.jump(st.index + k)}
|
||||
disabled={st.listening?.locked} onClick={() => player.jump(st.index + k)}
|
||||
>
|
||||
<Cover album={tr.album} size={40} />
|
||||
<span className={s.meta}>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
# Share a lock with manual deployments and config application.
|
||||
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
|
||||
exec 9<.management.lock
|
||||
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
|
||||
touch config.local
|
||||
compose() { docker compose --env-file config.local --env-file .env "$@"; }
|
||||
# Validate before stopping any container.
|
||||
compose config --quiet
|
||||
compose up -d --force-recreate --remove-orphans
|
||||
echo '→ ждём готовности api'
|
||||
for _ in $(seq 1 90); do
|
||||
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
|
||||
echo 'Конфигурация применена; API готов'
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo 'API не стал готов: проверьте docker compose logs api' >&2
|
||||
exit 1
|
||||
+15
-2
@@ -54,8 +54,10 @@ services:
|
||||
context: ..
|
||||
dockerfile: services/api/Dockerfile
|
||||
target: api
|
||||
args: *version
|
||||
env_file: .env
|
||||
args:
|
||||
<<: *version
|
||||
REVISION: ${PIPU_COMMIT:-}
|
||||
env_file: [config.local, .env]
|
||||
environment: &db-env
|
||||
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
|
||||
volumes:
|
||||
@@ -63,6 +65,7 @@ services:
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
|
||||
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
|
||||
- ${PIPU_DATA:-/srv/pipu}/management:/management
|
||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
@@ -112,6 +115,11 @@ services:
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
|
||||
TRAIN_HOUR: ${TRAIN_HOUR:-3}
|
||||
LIBRARY_ROOT: /library
|
||||
LIBRARY_HOST_ROOT: /library-host
|
||||
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
||||
@@ -124,6 +132,11 @@ services:
|
||||
<<: *db-env
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
|
||||
TRAIN_HOUR: ${TRAIN_HOUR:-3}
|
||||
LIBRARY_ROOT: /library
|
||||
LIBRARY_HOST_ROOT: /library-host
|
||||
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
|
||||
volumes:
|
||||
- *library-ro
|
||||
- *library-host-ro
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
[
|
||||
{
|
||||
"key": "MANAGEMENT_DIR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/management",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "HTTP_ADDR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": ":8080",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "DATABASE_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "Compose: POSTGRES_PASSWORD → postgres:5432/pipu",
|
||||
"secret": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "REDIS_URL",
|
||||
"group": "shared",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "LOG_LEVEL",
|
||||
"group": "shared",
|
||||
"type": "string",
|
||||
"default_value": "info",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "MIGRATE_ON_START",
|
||||
"group": "api",
|
||||
"type": "boolean",
|
||||
"default_value": "true",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "SHUTDOWN_TIMEOUT",
|
||||
"group": "api",
|
||||
"type": "duration",
|
||||
"default_value": "15s",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "SUPPORTED_LOCALES",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "ru,en",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "MIN_ANDROID_VERSION_CODE",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "0",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "JWT_PRIVATE_KEY",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "JWT_PREVIOUS_KEYS",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "ACCESS_TOKEN_TTL",
|
||||
"group": "api",
|
||||
"type": "duration",
|
||||
"default_value": "15m",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "COOKIE_SECURE",
|
||||
"group": "api",
|
||||
"type": "boolean",
|
||||
"default_value": "true",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "BOOTSTRAP_ADMIN_USERNAME",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "admin",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "BOOTSTRAP_ADMIN_PASSWORD",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/library",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_HOST_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/library-host",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "STORAGE_AGENT_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "http://storage-agent:8090",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "INTERNAL_TOKEN",
|
||||
"group": "shared",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "FFPROBE_PATH",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "ffprobe",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "FPCALC_PATH",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "fpcalc",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "STREAM_SIGNING_KEY",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "CACHE_DIR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/cache",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "FFMPEG_PATH",
|
||||
"group": "shared",
|
||||
"type": "string",
|
||||
"default_value": "ffmpeg",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "X_ACCEL",
|
||||
"group": "api",
|
||||
"type": "boolean",
|
||||
"default_value": "true",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "TRANSCODE_CONCURRENCY",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "0",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "TRANSCODE_CACHE_LIMIT_GB",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "50",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "RECOMMENDER_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "http://recommender:8000",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "GENIUS_ACCESS_TOKEN",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "GENIUS_REQUEST_INTERVAL",
|
||||
"group": "api",
|
||||
"type": "duration",
|
||||
"default_value": "2s",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "MEILI_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "http://meilisearch:7700",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "MEILI_MASTER_KEY",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "GITEA_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "GITEA_REPO",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "GITEA_TOKEN",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "UPDATES_DIR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/updates",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "UPLOADS_DIR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/uploads",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_RW_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/library-rw",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_HOST_RW_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/library-host-rw",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "UPLOADS_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/uploads",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "PIPU_FILE_UID",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "-1",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "PIPU_FILE_GID",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "-1",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "PIPU_VERSION",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "dev",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "PIPU_DATA",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "/srv/pipu",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_HOST",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "COMPOSE_FILE",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "compose.yaml:compose.tls.yaml",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "DOMAIN",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "music.example.com",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "ACME_EMAIL",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "admin@example.com",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "HTTP_PORT",
|
||||
"group": "deployment",
|
||||
"type": "integer",
|
||||
"default_value": "80",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "POSTGRES_PASSWORD",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "CONTEXTS_PATH",
|
||||
"group": "recommender",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "TRAIN_HOUR",
|
||||
"group": "recommender",
|
||||
"type": "integer",
|
||||
"default_value": "3",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "PIPU_COMMIT",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Host setup shared by first install and updates.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
DEPLOY="$(pwd -P)"
|
||||
ENSURE=0
|
||||
case "${1:-}" in
|
||||
--ensure) ENSURE=1 ;;
|
||||
'') ;;
|
||||
*) echo "Usage: bash install-management.sh [--ensure]" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# The active worker owns this process tree (systemd KillMode=control-group).
|
||||
# Restarting it here would kill the update. It refreshes itself after success.
|
||||
if [ "$ENSURE" = 1 ] && [ -n "${PIPU_UPDATE_SOURCE:-}" ]; then
|
||||
systemctl is-active --quiet pipu-management.service || {
|
||||
echo 'The management worker must stay active during its own update' >&2
|
||||
exit 1
|
||||
}
|
||||
echo '→ агент управления продолжает обновление; новая копия применится после завершения'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DATA="${PIPU_DATA:-$(sed -n 's/^PIPU_DATA=//p' .env)}"
|
||||
DATA="${DATA:-/srv/pipu}"
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
command -v sudo >/dev/null || { echo 'Для настройки агента запустите обновление с правами root (sudo)' >&2; exit 1; }
|
||||
echo '→ для настройки системного сервиса агента нужны права sudo'
|
||||
exec sudo -- env "PIPU_DATA=$DATA" bash "$DEPLOY/install-management.sh" "$@"
|
||||
fi
|
||||
|
||||
command -v python3 >/dev/null || { echo 'Install python3 (>=3.10) first' >&2; exit 1; }
|
||||
python3 -c 'import sys; assert sys.version_info >= (3, 10), "Python >=3.10 required"'
|
||||
[ -d /etc/systemd/system ] || { echo 'systemd is required for server management' >&2; exit 1; }
|
||||
RESTART=0
|
||||
mkdir -p "$DATA/management" /var/lib/pipu-management /usr/local/lib/pipu
|
||||
chown 65532:65532 "$DATA/management"
|
||||
chmod 700 "$DATA/management" /var/lib/pipu-management
|
||||
# Copy outside the checkout: switching revisions must not replace a running worker.
|
||||
if ! cmp -s server-agent.py /usr/local/lib/pipu/server-agent.py; then
|
||||
install -m 700 server-agent.py /usr/local/lib/pipu/server-agent.py.new
|
||||
mv -f /usr/local/lib/pipu/server-agent.py.new /usr/local/lib/pipu/server-agent.py
|
||||
RESTART=1
|
||||
fi
|
||||
touch config.local
|
||||
chmod 600 config.local
|
||||
if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER" config.local; fi
|
||||
# systemd quoted arguments escape backslashes, quotes and specifiers.
|
||||
quote_unit() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/%/%%/g'; }
|
||||
DEPLOY_UNIT="$(quote_unit "$DEPLOY")"
|
||||
MAILBOX_UNIT="$(quote_unit "$DATA/management")"
|
||||
UNIT_TMP="$(mktemp)"
|
||||
trap 'rm -f "$UNIT_TMP"' EXIT
|
||||
cat > "$UNIT_TMP" <<EOF
|
||||
[Unit]
|
||||
Description=Pipu Music server management agent
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /usr/local/lib/pipu/server-agent.py --deploy-dir "$DEPLOY_UNIT" --mailbox "$MAILBOX_UNIT" --state-dir /var/lib/pipu-management
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
UMask=0077
|
||||
KillMode=control-group
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
if ! cmp -s "$UNIT_TMP" /etc/systemd/system/pipu-management.service; then
|
||||
install -m 644 "$UNIT_TMP" /etc/systemd/system/pipu-management.service
|
||||
systemctl daemon-reload
|
||||
RESTART=1
|
||||
fi
|
||||
systemctl enable pipu-management.service
|
||||
if [ "$ENSURE" = 0 ] || [ "$RESTART" = 1 ] || ! systemctl is-active --quiet pipu-management.service; then
|
||||
systemctl restart pipu-management.service
|
||||
fi
|
||||
systemctl is-active --quiet pipu-management.service
|
||||
echo '→ управление сервером: pipu-management.service'
|
||||
@@ -118,4 +118,6 @@ if [ -n "$LIBRARY_HOST" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
bash ./install-management.sh
|
||||
|
||||
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
|
||||
@@ -3,4 +3,5 @@
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
# hash-wasm считает SHA-256 загрузок в Web Worker; разрешаем WASM без JavaScript eval.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
@@ -0,0 +1,498 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-side management worker. No network listener or Docker socket in the API.
|
||||
|
||||
Only fixed operations cross the shared mailbox. Private state stays outside it.
|
||||
Run with Python >= 3.10; installed by install-management.sh.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class ManagementError(Exception):
|
||||
def __init__(self, code, status=400):
|
||||
super().__init__(code)
|
||||
self.code, self.status = code, status
|
||||
|
||||
|
||||
def atomic(path, value, mode=0o600, preserve_owner=False):
|
||||
fd, name = tempfile.mkstemp(dir=path.parent, prefix=".management-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as out:
|
||||
os.chmod(name, mode)
|
||||
if preserve_owner and path.exists() and hasattr(os, "chown"):
|
||||
previous = path.stat()
|
||||
os.chown(name, previous.st_uid, previous.st_gid)
|
||||
out.write(value)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(name, path)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
def read_env(path):
|
||||
"""Read literal dotenv values, including the quoted format we write.
|
||||
|
||||
Compose performs interpolation; this reader never executes dotenv contents.
|
||||
"""
|
||||
values = {}
|
||||
if not path.exists():
|
||||
return values
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = re.match(r"^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*)$", line.strip())
|
||||
if not match:
|
||||
continue
|
||||
key, value = match.groups()
|
||||
value = value.strip()
|
||||
if value.startswith("'") and value.endswith("'"):
|
||||
value = value[1:-1].replace("\\'", "'")
|
||||
elif value.startswith('"') and value.endswith('"'):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
value = value[1:-1]
|
||||
else:
|
||||
value = value.split(" #", 1)[0].strip()
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def validate_url(url):
|
||||
if not isinstance(url, str) or len(url) > 2048 or re.search(r"[\s\x00-\x1f]", url):
|
||||
raise ManagementError("management.source_invalid")
|
||||
if re.fullmatch(r"git@[A-Za-z0-9.-]+:[A-Za-z0-9_./-]+", url):
|
||||
return
|
||||
parsed = urlsplit(url)
|
||||
if (parsed.scheme not in ("https", "http", "ssh") or not parsed.hostname
|
||||
or parsed.password or parsed.query or parsed.fragment
|
||||
or (parsed.username and not (parsed.scheme == "ssh" and parsed.username == "git"))
|
||||
or not parsed.path.strip("/")):
|
||||
raise ManagementError("management.source_invalid")
|
||||
|
||||
|
||||
def version_key(tag):
|
||||
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?", tag)
|
||||
if not match:
|
||||
return None
|
||||
major, minor, patch, rc = match.groups()
|
||||
return int(major), int(minor), int(patch), 1 if rc is None else 0, int(rc or 0)
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def sensitive_url(key, value):
|
||||
"""URL credentials also occur in userinfo, query strings and fragments."""
|
||||
if not key.endswith("_URL") or not value:
|
||||
return False
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
return bool(parsed.username or parsed.password or parsed.query or parsed.fragment)
|
||||
except ValueError:
|
||||
# A malformed URL must not bypass masking.
|
||||
return True
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(self, deploy, mailbox, private):
|
||||
self.deploy, self.mailbox, self.private = deploy, mailbox, private
|
||||
self.lock = threading.Lock()
|
||||
self.cache = {}
|
||||
self.job = self.load("job.json", None)
|
||||
# systemd KillMode=control-group stops descendants too. Never claim success
|
||||
# after a reboot or interrupted deployment.
|
||||
if self.job and self.job["state"] == "running":
|
||||
self.job.update(state="failed", finished_at=now(), log="Agent interrupted; inspect deployment before retrying.")
|
||||
self.save("job.json", self.job)
|
||||
|
||||
def load(self, name, default):
|
||||
path = self.private / name
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else default
|
||||
|
||||
def save(self, name, value):
|
||||
atomic(self.private / name, json.dumps(value, ensure_ascii=False))
|
||||
|
||||
def git_environment(self):
|
||||
return {"GIT_TERMINAL_PROMPT": "0", "GIT_SSH_COMMAND": "ssh -oBatchMode=yes",
|
||||
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "safe.directory", "GIT_CONFIG_VALUE_0": str(self.deploy.parent)}
|
||||
|
||||
def command(self, args, timeout=30, **kwargs):
|
||||
return subprocess.run(args, cwd=self.deploy, timeout=timeout, check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
env={**os.environ, **self.git_environment()}, **kwargs).stdout
|
||||
|
||||
def sources(self):
|
||||
sources = self.load("sources.json", None)
|
||||
if sources is None:
|
||||
url = self.command(["git", "remote", "get-url", "origin"]).strip()
|
||||
validate_url(url)
|
||||
sources = [{"id": "origin", "name": "origin", "url": url}]
|
||||
self.save("sources.json", sources)
|
||||
return sources
|
||||
|
||||
def refs(self, url, fresh=False):
|
||||
cached = self.cache.get(url)
|
||||
if cached and not fresh and time.monotonic() - cached[0] < 60:
|
||||
return cached[1]
|
||||
output = self.command(["git", "-c", "protocol.file.allow=never", "-c", "protocol.ext.allow=never",
|
||||
"ls-remote", "--heads", "--tags", "--", url], timeout=20)
|
||||
refs = dict(line.split()[::-1] for line in output.splitlines() if len(line.split()) == 2)
|
||||
self.cache[url] = time.monotonic(), refs
|
||||
return refs
|
||||
|
||||
def schema(self):
|
||||
return json.loads((self.deploy / "config.schema.json").read_text(encoding="utf-8"))
|
||||
|
||||
def revision(self):
|
||||
return hashlib.sha256((self.deploy / "config.local").read_bytes()).hexdigest()
|
||||
|
||||
def effective_revision(self, ignore_local=False):
|
||||
env = read_env(self.deploy / ".env")
|
||||
local = {} if ignore_local else read_env(self.deploy / "config.local")
|
||||
effective = {item["key"]: env.get(item["key"], local.get(item["key"], item["default_value"]))
|
||||
for item in self.schema() if not item["readonly"]}
|
||||
return hashlib.sha256(json.dumps(effective, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
def config(self):
|
||||
env = read_env(self.deploy / ".env")
|
||||
local = read_env(self.deploy / "config.local")
|
||||
entries = []
|
||||
for item in self.schema():
|
||||
key = item["key"]
|
||||
source = "env" if key in env else "local" if key in local else "default"
|
||||
effective = env.get(key, local.get(key, item["default_value"]))
|
||||
secret = item["secret"] or any(sensitive_url(key, value) for value in
|
||||
(env.get(key), local.get(key), item["default_value"]))
|
||||
def display(value):
|
||||
return "••••••••" if secret and value else value
|
||||
entries.append({**item, "secret": secret, "default_value": display(item["default_value"]),
|
||||
"env": display(env.get(key)), "local": display(local.get(key)),
|
||||
"effective": display(effective), "source": source})
|
||||
effective = self.effective_revision()
|
||||
applied = self.load("applied-effective.json", None)
|
||||
if applied is None and (not local or self.load("applied.json", None) == self.revision()):
|
||||
self.save("applied-effective.json", effective)
|
||||
applied = effective
|
||||
# Changes shadowed by .env do not change the running configuration.
|
||||
empty = self.effective_revision(ignore_local=True)
|
||||
return {"entries": entries, "revision": self.revision(),
|
||||
"restart_required": effective != (applied or empty)}
|
||||
|
||||
def installed_commit(self, body):
|
||||
"""Trust the running API's build identity, never the checkout or .env.
|
||||
|
||||
The checkout can already point at a failed update. Legacy builds contain
|
||||
git-describe/main hashes or a release tag, which we resolve locally.
|
||||
"""
|
||||
revision = body.get("_running_commit", "")
|
||||
if re.fullmatch(r"[a-f0-9]{40,64}", revision):
|
||||
return revision
|
||||
version = body.get("_running_version", "")
|
||||
match = re.fullmatch(r"(?:.*-g|main-)([a-f0-9]{7,64})", version)
|
||||
candidates = [match[1]] if match else []
|
||||
if version_key(version):
|
||||
candidates = ["refs/tags/v" + version.removeprefix("v"), "refs/tags/" + version.removeprefix("v")]
|
||||
for candidate in candidates:
|
||||
try:
|
||||
commit = self.command(["git", "rev-parse", "--verify", candidate + "^{commit}"]).strip()
|
||||
if re.fullmatch(r"[a-f0-9]{40,64}", commit):
|
||||
return commit
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
def update_target(self, refs, body):
|
||||
channel = body.get("channel") or "latest"
|
||||
if channel not in ("latest", "version", "main"):
|
||||
raise ManagementError("management.target_invalid")
|
||||
if channel == "latest":
|
||||
tags = [r[10:] for r in refs if r.startswith("refs/tags/") and version_key(r[10:]) and "-rc." not in r]
|
||||
if not tags:
|
||||
return "", ""
|
||||
target = max(tags, key=version_key)
|
||||
else:
|
||||
target = "main" if channel == "main" else body.get("version") or ""
|
||||
if not target:
|
||||
return "", ""
|
||||
if target != "main" and not version_key(target):
|
||||
raise ManagementError("management.target_invalid")
|
||||
ref = "refs/heads/main" if target == "main" else "refs/tags/" + target
|
||||
commit = refs.get(ref + "^{}", refs.get(ref, ""))
|
||||
if not re.fullmatch(r"[a-f0-9]{40,64}", commit):
|
||||
return target, ""
|
||||
return target, commit
|
||||
|
||||
def update_state(self, target, commit, current, body):
|
||||
if body.get("channel") == "version" and not body.get("version"):
|
||||
return "select_version"
|
||||
if not commit:
|
||||
return "unavailable"
|
||||
if not current:
|
||||
return "unknown"
|
||||
if current == commit:
|
||||
return "current"
|
||||
try:
|
||||
self.command(["git", "merge-base", "--is-ancestor", commit, current])
|
||||
return "ahead"
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
# A selected source's commit may not have been fetched locally yet.
|
||||
pass
|
||||
# A stable tag behind a git-describe build is not a newer release.
|
||||
running = body.get("_running_version", "")
|
||||
base = re.match(r"v?(\d+\.\d+\.\d+)(?:-rc\.\d+)?", running)
|
||||
target_key = version_key(target)
|
||||
if target_key and base:
|
||||
running_key = version_key(base[0])
|
||||
if target_key[:3] < running_key[:3] or (target_key[:3] == running_key[:3] and re.search(r"-\d+-g[0-9a-f]+$", running)):
|
||||
return "ahead"
|
||||
return "available"
|
||||
|
||||
def save_config(self, body):
|
||||
if self.job and self.job["state"] == "running":
|
||||
raise ManagementError("management.busy", 409)
|
||||
if body.get("revision") != self.revision():
|
||||
raise ManagementError("management.config_conflict", 409)
|
||||
values = body.get("values")
|
||||
if not isinstance(values, dict) or len(values) > 200:
|
||||
raise ManagementError("management.config_invalid")
|
||||
schema = {v["key"]: v for v in self.schema()}
|
||||
local = read_env(self.deploy / "config.local")
|
||||
for key, value in values.items():
|
||||
item = schema.get(key)
|
||||
if not item or item["readonly"]:
|
||||
raise ManagementError("management.config_readonly")
|
||||
if value is None:
|
||||
local.pop(key, None)
|
||||
continue
|
||||
if not isinstance(value, str) or len(value) > 8192 or any(c in value for c in "\r\n\x00"):
|
||||
raise ManagementError("management.config_invalid")
|
||||
if item["type"] == "boolean" and value not in ("true", "false", "1", "0"):
|
||||
raise ManagementError("management.config_invalid")
|
||||
if item["type"] == "integer" and not re.fullmatch(r"-?\d+", value):
|
||||
raise ManagementError("management.config_invalid")
|
||||
if key == "LOG_LEVEL" and value not in ("debug", "info", "warn", "error"):
|
||||
raise ManagementError("management.config_invalid")
|
||||
if item["type"] == "integer" and key != "TRAIN_HOUR":
|
||||
minimum = -1 if key in ("PIPU_FILE_UID", "PIPU_FILE_GID") else 0
|
||||
if int(value) < minimum:
|
||||
raise ManagementError("management.config_invalid")
|
||||
if key == "TRAIN_HOUR" and not -1 <= int(value) <= 23:
|
||||
raise ManagementError("management.config_invalid")
|
||||
if key.endswith("_PORT") and not 1 <= int(value) <= 65535:
|
||||
raise ManagementError("management.config_invalid")
|
||||
if item["type"] == "duration" and not re.fullmatch(r"(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+", value):
|
||||
raise ManagementError("management.config_invalid")
|
||||
local[key] = value
|
||||
# Single quotes make dollar signs literal to Compose. Newlines are rejected.
|
||||
content = "# Managed by Pipu. Environment and .env override these values.\n"
|
||||
content += "".join(k + "='" + v.replace("'", "\\'") + "'\n" for k, v in sorted(local.items()))
|
||||
path = self.deploy / "config.local"
|
||||
previous = path.read_text(encoding="utf-8")
|
||||
atomic(path, content, preserve_owner=True)
|
||||
try:
|
||||
self.command([*self.compose(), "config", "--quiet"])
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
atomic(path, previous, preserve_owner=True)
|
||||
raise ManagementError("management.config_invalid") from None
|
||||
self.audit("config.save", list(values))
|
||||
return self.config()
|
||||
|
||||
def compose(self):
|
||||
return ["docker", "compose", "--env-file", "config.local", "--env-file", ".env"]
|
||||
|
||||
def audit(self, action, details):
|
||||
# Deliberately no configuration values or credentials in this log.
|
||||
with (self.private / "audit.jsonl").open("a", encoding="utf-8") as out:
|
||||
out.write(json.dumps({"at": now(), "actor": getattr(self, "actor", ""), "action": action, "details": details}) + "\n")
|
||||
|
||||
def start_job(self, kind, target, command, extra_env=None):
|
||||
if self.job and self.job["state"] == "running":
|
||||
raise ManagementError("management.busy", 409)
|
||||
job = {"id": str(uuid.uuid4()), "kind": kind, "state": "running", "target": target,
|
||||
"started_at": now(), "finished_at": "", "log": ""}
|
||||
self.job = job
|
||||
self.save("job.json", job)
|
||||
revision = self.revision()
|
||||
self.audit(kind + ".start", {"id": job["id"], "target": target})
|
||||
threading.Thread(target=self.run_job, args=(job, command, extra_env or {}, revision), daemon=True).start()
|
||||
return dict(job)
|
||||
|
||||
def run_job(self, job, command, extra_env, revision):
|
||||
effective_revision = self.effective_revision()
|
||||
# Let the API deliver the accepted job before recreating its container.
|
||||
time.sleep(2)
|
||||
# Capture full output privately, expose only a bounded redacted tail.
|
||||
secrets = [v for k, v in {**read_env(self.deploy / "config.local"), **read_env(self.deploy / ".env")}.items()
|
||||
if re.search(r"TOKEN|PASSWORD|KEY|SECRET|DATABASE_URL|REDIS_URL", k) and len(v) >= 4]
|
||||
def redact(text):
|
||||
for value in secrets:
|
||||
text = text.replace(value, "[redacted]")
|
||||
return re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[redacted]@", text)[-64000:]
|
||||
logpath = self.private / (job["id"] + ".log")
|
||||
result = 1
|
||||
try:
|
||||
with logpath.open("w", encoding="utf-8") as output:
|
||||
process = subprocess.Popen(command, cwd=self.deploy, stdout=output, stderr=subprocess.STDOUT,
|
||||
env={**os.environ, **self.git_environment(), **extra_env}, start_new_session=True)
|
||||
deadline = time.monotonic() + 7200
|
||||
while process.poll() is None:
|
||||
if time.monotonic() > deadline:
|
||||
import signal
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
break
|
||||
with self.lock:
|
||||
with logpath.open("rb") as output:
|
||||
output.seek(max(0, logpath.stat().st_size - 128000))
|
||||
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
|
||||
self.save("job.json", job)
|
||||
time.sleep(1)
|
||||
result = process.returncode
|
||||
with logpath.open("rb") as output:
|
||||
output.seek(max(0, logpath.stat().st_size - 128000))
|
||||
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
|
||||
if result == 0:
|
||||
self.save("applied.json", revision)
|
||||
self.save("applied-effective.json", effective_revision)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
result = 1
|
||||
job["log"] += "\nCould not execute deployment command. Inspect journalctl -u pipu-management."
|
||||
finally:
|
||||
with self.lock:
|
||||
job.update(state="succeeded" if result == 0 else "failed", finished_at=now())
|
||||
self.save("job.json", job)
|
||||
|
||||
def handle(self, op, body):
|
||||
if op == "config.get":
|
||||
return self.config()
|
||||
if op == "config.save":
|
||||
return self.save_config(body)
|
||||
if op == "config.apply":
|
||||
return self.start_job("config", "config.local", ["bash", str(self.deploy / "apply-config.sh")])
|
||||
if op == "sources.save":
|
||||
if self.job and self.job["state"] == "running":
|
||||
raise ManagementError("management.busy", 409)
|
||||
sources = body.get("sources")
|
||||
if not isinstance(sources, list) or not 1 <= len(sources) <= 20:
|
||||
raise ManagementError("management.source_invalid")
|
||||
ids = set()
|
||||
for source in sources:
|
||||
if (not isinstance(source, dict) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", source.get("id", ""))
|
||||
or not isinstance(source.get("name"), str) or not 1 <= len(source["name"].strip()) <= 100
|
||||
or source["id"] in ids):
|
||||
raise ManagementError("management.source_invalid")
|
||||
validate_url(source.get("url"))
|
||||
ids.add(source["id"])
|
||||
self.save("sources.json", sources)
|
||||
self.audit("sources.save", list(ids))
|
||||
return {"sources": sources}
|
||||
sources = self.sources()
|
||||
selected = next((s for s in sources if s["id"] == (body.get("source_id") or sources[0]["id"])), None)
|
||||
if not selected:
|
||||
raise ManagementError("management.source_invalid")
|
||||
if op == "updates.get":
|
||||
versions, error, refs = [], "", {}
|
||||
try:
|
||||
refs = self.refs(selected["url"], fresh=bool(body.get("fresh")))
|
||||
versions = sorted([r.removeprefix("refs/tags/") for r in refs if r.startswith("refs/tags/") and version_key(r[10:])], key=version_key, reverse=True)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
error = "management.source_unavailable"
|
||||
current = self.installed_commit(body)
|
||||
target, commit = self.update_target(refs, body)
|
||||
return {"sources": sources, "versions": versions, "source_error": error,
|
||||
"current_commit": current, "target_version": target, "target_commit": commit,
|
||||
"update_state": self.update_state(target, commit, current, body),
|
||||
"current_version": read_env(self.deploy / ".env").get("PIPU_VERSION", ""), "job": self.job}
|
||||
if op == "updates.start":
|
||||
if self.job and self.job["state"] == "running":
|
||||
raise ManagementError("management.busy", 409)
|
||||
refs = self.refs(selected["url"], fresh=True)
|
||||
target, commit = self.update_target(refs, body)
|
||||
ref = "refs/heads/main" if target == "main" else "refs/tags/" + target
|
||||
if not commit:
|
||||
raise ManagementError("management.target_invalid")
|
||||
state = self.update_state(target, commit, self.installed_commit(body), body)
|
||||
if state != "available":
|
||||
code = {"current": "management.already_installed", "ahead": "management.downgrade", "unknown": "management.version_unknown"}.get(state, "management.target_invalid")
|
||||
raise ManagementError(code, 409)
|
||||
if body.get("expected_commit") and body["expected_commit"] != commit:
|
||||
raise ManagementError("management.target_changed", 409)
|
||||
return self.start_job("update", target, ["bash", str(self.deploy / "update.sh"), target],
|
||||
{"PIPU_UPDATE_SOURCE": selected["url"], "PIPU_UPDATE_COMMIT": commit,
|
||||
"PIPU_UPDATE_REF": ref})
|
||||
raise ManagementError("management.operation_invalid")
|
||||
|
||||
def tick(self):
|
||||
atomic(self.mailbox / "heartbeat", str(time.time()), 0o644)
|
||||
for path in sorted(self.mailbox.glob("*.request.json")):
|
||||
if path.is_symlink() or not re.fullmatch(r"[a-f0-9-]{36}\.request\.json", path.name):
|
||||
continue
|
||||
response = self.mailbox / path.name.replace(".request.json", ".response.json")
|
||||
if response.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
continue
|
||||
try:
|
||||
if path.stat().st_size > 131072:
|
||||
raise ManagementError("management.request_invalid")
|
||||
request = json.loads(path.read_text(encoding="utf-8"))
|
||||
if time.time() - request["created_at"] > 45:
|
||||
raise ManagementError("management.request_expired", 409)
|
||||
# Claim before executing: a worker crash must never replay a mutation.
|
||||
path.unlink(missing_ok=True)
|
||||
with self.lock:
|
||||
self.actor = request.get("actor", "")
|
||||
result = {"data": self.handle(request["operation"], request.get("body") or {})}
|
||||
except ManagementError as error:
|
||||
result = {"code": error.code, "status": error.status}
|
||||
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
|
||||
result = {"code": "management.agent_error", "status": 502}
|
||||
atomic(response, json.dumps(result, ensure_ascii=False), 0o644)
|
||||
path.unlink(missing_ok=True)
|
||||
for path in self.mailbox.glob("*.response.json"):
|
||||
if not path.is_symlink() and time.time() - path.stat().st_mtime > 120:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main():
|
||||
import fcntl # Linux host only; tests import this module on Windows too.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--deploy-dir", type=Path, required=True)
|
||||
parser.add_argument("--mailbox", type=Path, required=True)
|
||||
parser.add_argument("--state-dir", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.state_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with (args.state_dir / "lock").open("w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
agent = Agent(args.deploy_dir.resolve(), args.mailbox.resolve(), args.state_dir.resolve())
|
||||
while True:
|
||||
# Refresh the installed worker only after a successful deployment has
|
||||
# persisted its final status. The running worker survives checkout/build.
|
||||
with agent.lock:
|
||||
installed = Path(__file__).resolve()
|
||||
candidate = agent.deploy / "server-agent.py"
|
||||
if (agent.job and agent.job["kind"] == "update" and agent.job["state"] == "succeeded"
|
||||
and installed == Path("/usr/local/lib/pipu/server-agent.py") and candidate.is_file()
|
||||
and candidate.read_bytes() != installed.read_bytes()):
|
||||
atomic(installed, candidate.read_text(encoding="utf-8"), 0o700)
|
||||
# The singleton lock is non-inheritable and released by exec.
|
||||
import sys
|
||||
os.execv(sys.executable, [sys.executable, str(installed), *sys.argv[1:]])
|
||||
agent.tick()
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Exercise deployment shell scripts with isolated host paths and command stubs."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASH = ("C:/Program Files/Git/bin/bash.exe" if os.name == "nt" else shutil.which("bash"))
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
@unittest.skipUnless(BASH and Path(BASH).exists(), "Bash unavailable")
|
||||
class DeploymentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.deploy = self.root / "deploy"
|
||||
self.bin = self.root / "bin"
|
||||
self.systemd = self.root / "systemd"
|
||||
for directory in (self.deploy, self.bin, self.systemd):
|
||||
directory.mkdir()
|
||||
self.trace = self.root / "trace"
|
||||
self.active = self.root / "active"
|
||||
# Git Bash on Windows prepends its binaries when importing Windows PATH.
|
||||
# Source our isolated PATH after shell initialization, including re-exec.
|
||||
shell_bin = self.bin.as_posix()
|
||||
if os.name == "nt":
|
||||
shell_bin = "/" + shell_bin[0].lower() + shell_bin[2:]
|
||||
shell_env = self.root / "bash-env"
|
||||
shell_env.write_text('export PATH="' + shell_bin + ':$PATH"\n', encoding="utf-8")
|
||||
self.env = {**os.environ, "PATH": str(self.bin) + os.pathsep + os.environ["PATH"],
|
||||
"BASH_ENV": shell_env.as_posix(),
|
||||
"TRACE": self.trace.as_posix(), "ACTIVE": self.active.as_posix()}
|
||||
for key in ("PIPU_UPDATE_REEXEC", "PIPU_UPDATE_SOURCE", "PIPU_UPDATE_COMMIT", "PIPU_UPDATE_REF", "PIPU_DATA", "SUDO_USER"):
|
||||
self.env.pop(key, None)
|
||||
(self.deploy / ".env").write_text("PIPU_DATA=" + (self.root / "data").as_posix() + "\nPIPU_VERSION=1.9.0\nJWT_PRIVATE_KEY=fixture\nSTREAM_SIGNING_KEY=fixture\nGITEA_URL=https://git.example.org\nGITEA_REPO=pipu/music\n", encoding="utf-8")
|
||||
self.stub("systemctl", 'echo "systemctl $*" >> "$TRACE"\ncase "$1" in is-active) test -f "$ACTIVE";; restart) touch "$ACTIVE";; esac\n')
|
||||
self.stub("id", 'echo "${TEST_UID:-0}"\n')
|
||||
self.stub("chown", 'exit 0\n')
|
||||
self.stub("python3", 'exit 0\n')
|
||||
self.stub("flock", 'exit 0\n')
|
||||
|
||||
def stub(self, name, body):
|
||||
path = self.bin / name
|
||||
path.write_text("#!/usr/bin/env bash\n" + body, encoding="utf-8", newline="\n")
|
||||
path.chmod(0o755)
|
||||
|
||||
def run_script(self, name, *args, extra=None):
|
||||
return subprocess.run([BASH, (self.deploy / name).as_posix(), *args], cwd=self.deploy,
|
||||
env={**self.env, **(extra or {})}, text=True, encoding="utf-8",
|
||||
errors="replace", capture_output=True, timeout=20)
|
||||
|
||||
def install_fixture(self):
|
||||
source = (SCRIPTS / "install-management.sh").read_text(encoding="utf-8")
|
||||
# Relocate host constants only; run the real installer logic unchanged.
|
||||
for original, replacement in (("/etc/systemd/system", self.systemd),
|
||||
("/var/lib/pipu-management", self.root / "state"),
|
||||
("/usr/local/lib/pipu", self.root / "installed")):
|
||||
source = source.replace(original, replacement.as_posix())
|
||||
(self.deploy / "install-management.sh").write_text(source, encoding="utf-8", newline="\n")
|
||||
(self.deploy / "server-agent.py").write_text("# worker fixture\n", encoding="utf-8")
|
||||
|
||||
def test_ensure_installs_starts_and_preserves_unchanged_running_worker(self):
|
||||
self.install_fixture()
|
||||
first = self.run_script("install-management.sh", "--ensure")
|
||||
self.assertEqual(0, first.returncode, first.stderr)
|
||||
self.assertTrue((self.root / "installed/server-agent.py").is_file())
|
||||
self.assertTrue((self.root / "data/management").is_dir())
|
||||
unit = (self.systemd / "pipu-management.service").read_text(encoding="utf-8")
|
||||
canonical = subprocess.run([BASH, "-c", 'cd "$1" && pwd -P', "test", self.deploy.as_posix()],
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
self.assertIn(canonical, unit)
|
||||
self.assertTrue(self.active.exists())
|
||||
self.trace.write_text("", encoding="utf-8")
|
||||
again = self.run_script("install-management.sh", "--ensure")
|
||||
self.assertEqual(0, again.returncode, again.stderr)
|
||||
self.assertNotIn("restart", self.trace.read_text())
|
||||
self.assertNotIn("daemon-reload", self.trace.read_text())
|
||||
(self.deploy / "server-agent.py").write_text("# upgraded worker\n", encoding="utf-8")
|
||||
updated = self.run_script("install-management.sh", "--ensure")
|
||||
self.assertEqual(0, updated.returncode, updated.stderr)
|
||||
self.assertIn("restart", self.trace.read_text())
|
||||
|
||||
def test_ensure_restarts_stopped_worker(self):
|
||||
self.install_fixture()
|
||||
self.assertEqual(0, self.run_script("install-management.sh", "--ensure").returncode)
|
||||
self.active.unlink()
|
||||
result = self.run_script("install-management.sh", "--ensure")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertTrue(self.active.exists())
|
||||
|
||||
def test_agent_owned_update_never_replaces_or_restarts_itself(self):
|
||||
self.install_fixture()
|
||||
self.active.touch()
|
||||
result = self.run_script("install-management.sh", "--ensure", extra={"PIPU_UPDATE_SOURCE": "https://git.example.org/pipu/music"})
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual("systemctl is-active --quiet pipu-management.service\n", self.trace.read_text())
|
||||
self.assertFalse((self.root / "installed").exists())
|
||||
|
||||
def test_nonroot_setup_delegates_privileged_step_to_sudo(self):
|
||||
self.install_fixture()
|
||||
self.stub("sudo", 'printf "%s\\n" "$@" > "$TRACE"\n')
|
||||
result = self.run_script("install-management.sh", "--ensure", extra={"TEST_UID": "1000"})
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
arguments = self.trace.read_text().splitlines()
|
||||
self.assertEqual(["--", "env"], arguments[:2])
|
||||
self.assertEqual("PIPU_DATA=" + (self.root / "data").as_posix(), arguments[2])
|
||||
self.assertEqual("--ensure", arguments[-1])
|
||||
|
||||
def update_fixture(self, install_exit=0):
|
||||
shutil.copyfile(SCRIPTS / "update.sh", self.deploy / "update.sh")
|
||||
target = self.root / "target-installer"
|
||||
target.write_text('echo "setup $*" >> "$TRACE"\nexit ' + str(install_exit) + '\n', encoding="utf-8", newline="\n")
|
||||
self.env["TARGET_INSTALLER"] = target.as_posix()
|
||||
(self.deploy / "install-management.sh").write_text("echo 'old setup must not run' >&2; exit 99\n", encoding="utf-8")
|
||||
self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; rev-parse) echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;; esac\n')
|
||||
self.stub("docker", 'echo "docker $*" >> "$TRACE"\n')
|
||||
|
||||
def test_update_installs_target_revision_worker_before_container_operations(self):
|
||||
self.update_fixture()
|
||||
result = self.run_script("update.sh", "main")
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
trace = self.trace.read_text()
|
||||
self.assertLess(trace.index("git checkout"), trace.index("setup --ensure"))
|
||||
self.assertLess(trace.index("setup --ensure"), trace.index("docker compose"))
|
||||
self.assertIn("up -d --remove-orphans", trace)
|
||||
self.assertIn("PIPU_COMMIT=" + "a" * 40, (self.deploy / ".env").read_text())
|
||||
|
||||
def test_failed_host_setup_stops_update_before_container_operations(self):
|
||||
self.update_fixture(install_exit=1)
|
||||
result = self.run_script("update.sh", "main")
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertNotIn("docker ", self.trace.read_text())
|
||||
self.assertIn("PIPU_VERSION=1.9.0", (self.deploy / ".env").read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Host worker tests: python3 -m unittest discover -s deploy -p 'test_*.py'."""
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("server_agent", Path(__file__).with_name("server-agent.py"))
|
||||
agent_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(agent_module)
|
||||
Agent, ManagementError = agent_module.Agent, agent_module.ManagementError
|
||||
|
||||
|
||||
class AgentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.deploy, self.mailbox, self.private = [self.root / n for n in ("deploy", "mailbox", "private")]
|
||||
for directory in (self.deploy, self.mailbox, self.private):
|
||||
directory.mkdir()
|
||||
self.schema = [
|
||||
{"key": "LOG_LEVEL", "group": "api", "type": "string", "default_value": "info", "secret": False, "readonly": False},
|
||||
{"key": "GITEA_TOKEN", "group": "api", "type": "string", "default_value": "", "secret": True, "readonly": False},
|
||||
{"key": "TRAIN_HOUR", "group": "recommender", "type": "integer", "default_value": "3", "secret": False, "readonly": False},
|
||||
{"key": "PIPU_DATA", "group": "deployment", "type": "string", "default_value": "/srv/pipu", "secret": False, "readonly": True},
|
||||
]
|
||||
(self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8")
|
||||
(self.deploy / "config.local").write_text("", encoding="utf-8")
|
||||
(self.deploy / ".env").write_text("LOG_LEVEL=warn\nGITEA_TOKEN=top-secret\n", encoding="utf-8")
|
||||
self.agent = Agent(self.deploy, self.mailbox, self.private)
|
||||
self.running_commit = "0" * 40
|
||||
self.agent.installed_commit = lambda body: body.get("_running_commit", self.running_commit)
|
||||
self.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}])
|
||||
|
||||
def test_unchanged_configuration_needs_no_restart_even_without_saved_baseline(self):
|
||||
self.assertFalse(self.agent.config()["restart_required"])
|
||||
with patch.object(self.agent, "command", return_value=""):
|
||||
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"LOG_LEVEL": "debug"}})
|
||||
self.assertFalse(result["restart_required"], "LOG_LEVEL is overridden by .env")
|
||||
|
||||
def test_returning_to_applied_values_removes_pending_restart(self):
|
||||
self.assertFalse(self.agent.config()["restart_required"])
|
||||
with patch.object(self.agent, "command", return_value=""):
|
||||
changed = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}})
|
||||
self.assertTrue(changed["restart_required"])
|
||||
restored = self.agent.save_config({"revision": changed["revision"], "values": {"TRAIN_HOUR": None}})
|
||||
self.assertFalse(restored["restart_required"])
|
||||
|
||||
def test_explicit_default_value_does_not_require_a_restart(self):
|
||||
self.assertFalse(self.agent.config()["restart_required"])
|
||||
with patch.object(self.agent, "command", return_value=""):
|
||||
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "3"}})
|
||||
self.assertFalse(result["restart_required"])
|
||||
|
||||
def test_update_check_and_start_both_reject_installed_main_commit(self):
|
||||
refs = {"refs/heads/main": self.running_commit}
|
||||
body = {"source_id": "origin", "channel": "main"}
|
||||
with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job") as start:
|
||||
self.assertEqual("current", self.agent.handle("updates.get", body)["update_state"])
|
||||
with self.assertRaises(ManagementError) as error:
|
||||
self.agent.handle("updates.start", body)
|
||||
self.assertEqual("management.already_installed", error.exception.code)
|
||||
start.assert_not_called()
|
||||
|
||||
def test_check_distinguishes_missing_selection_and_changed_target(self):
|
||||
with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job") as start:
|
||||
self.assertEqual("select_version", self.agent.handle("updates.get", {"channel": "version"})["update_state"])
|
||||
with self.assertRaises(ManagementError) as error:
|
||||
self.agent.handle("updates.start", {"channel": "main", "expected_commit": "b" * 40})
|
||||
self.assertEqual("management.target_changed", error.exception.code)
|
||||
start.assert_not_called()
|
||||
|
||||
def test_unknown_installed_commit_and_older_release_cannot_be_installed(self):
|
||||
with patch.object(self.agent, "refs", return_value={"refs/tags/v1.8.0": "a" * 40}), patch.object(self.agent, "start_job") as start:
|
||||
for body, state in [({"_running_commit": ""}, "unknown"), ({"_running_version": "1.9.0-1-gbc55528"}, "ahead")]:
|
||||
self.assertEqual(state, self.agent.handle("updates.get", body)["update_state"])
|
||||
with self.assertRaises(ManagementError):
|
||||
self.agent.handle("updates.start", body)
|
||||
start.assert_not_called()
|
||||
|
||||
def test_installed_identity_uses_running_build_and_resolves_legacy_describe(self):
|
||||
with patch.object(self.agent, "command", return_value="a" * 40) as command:
|
||||
self.assertEqual("b" * 40, Agent.installed_commit(self.agent, {"_running_commit": "b" * 40}))
|
||||
command.assert_not_called()
|
||||
self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "1.9.0-1-gbc55528"}))
|
||||
self.assertEqual("bc55528^{commit}", command.call_args.args[0][-1])
|
||||
self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "main-bc55528"}))
|
||||
self.assertEqual("", Agent.installed_commit(self.agent, {"_running_version": "0.0.0-dev"}))
|
||||
|
||||
def test_env_priority_and_secrets_never_returned(self):
|
||||
(self.deploy / "config.local").write_text("LOG_LEVEL=debug\nTRAIN_HOUR=5\n", encoding="utf-8")
|
||||
config = self.agent.config()
|
||||
entries = {v["key"]: v for v in config["entries"]}
|
||||
self.assertEqual(("warn", "env", "debug"), tuple(entries["LOG_LEVEL"][k] for k in ("effective", "source", "local")))
|
||||
self.assertEqual(("5", "local"), tuple(entries["TRAIN_HOUR"][k] for k in ("effective", "source")))
|
||||
self.assertNotIn("top-secret", json.dumps(config))
|
||||
|
||||
def test_save_roundtrip_reset_and_conflict(self):
|
||||
with patch.object(self.agent, "command", return_value=""):
|
||||
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "7", "GITEA_TOKEN": "a$'\\b#c"}})
|
||||
self.assertEqual("a$'\\b#c", agent_module.read_env(self.deploy / "config.local")["GITEA_TOKEN"])
|
||||
self.assertTrue(result["restart_required"])
|
||||
self.assertNotIn("a$", json.dumps(result))
|
||||
self.assertNotIn("a$", (self.private / "audit.jsonl").read_text())
|
||||
with self.assertRaises(ManagementError) as raised:
|
||||
self.agent.save_config({"revision": "stale", "values": {"TRAIN_HOUR": "8"}})
|
||||
self.assertEqual(409, raised.exception.status)
|
||||
self.agent.save_config({"revision": result["revision"], "values": {"TRAIN_HOUR": None}})
|
||||
self.assertNotIn("TRAIN_HOUR", agent_module.read_env(self.deploy / "config.local"))
|
||||
|
||||
def test_secret_defaults_and_shadowed_values_never_leave_worker(self):
|
||||
self.schema[1]["default_value"] = "default-canary"
|
||||
(self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8")
|
||||
(self.deploy / "config.local").write_text("GITEA_TOKEN=local-canary\n", encoding="utf-8")
|
||||
config = self.agent.config()
|
||||
encoded = json.dumps(config)
|
||||
for value in ("top-secret", "local-canary", "default-canary"):
|
||||
self.assertNotIn(value, encoded)
|
||||
token = next(entry for entry in config["entries"] if entry["key"] == "GITEA_TOKEN")
|
||||
for field in ("env", "local", "effective", "default_value"):
|
||||
self.assertEqual("••••••••", token[field])
|
||||
|
||||
def test_real_catalog_masks_every_secret_and_credential_bearing_url(self):
|
||||
schema = json.loads(Path(__file__).with_name("config.schema.json").read_text(encoding="utf-8"))
|
||||
(self.deploy / "config.schema.json").write_text(json.dumps(schema), encoding="utf-8")
|
||||
secrets = {item["key"] for item in schema if item["secret"]}
|
||||
self.assertTrue({"POSTGRES_PASSWORD", "DATABASE_URL", "REDIS_URL", "JWT_PRIVATE_KEY",
|
||||
"JWT_PREVIOUS_KEYS", "BOOTSTRAP_ADMIN_PASSWORD", "INTERNAL_TOKEN",
|
||||
"STREAM_SIGNING_KEY", "GENIUS_ACCESS_TOKEN", "MEILI_MASTER_KEY", "GITEA_TOKEN"} <= secrets)
|
||||
self.assertNotIn("ACCESS_TOKEN_TTL", secrets)
|
||||
env = {key: f"env-canary-{key}" for key in secrets}
|
||||
local = {key: f"local-canary-{key}" for key in secrets}
|
||||
env.update(GITEA_URL="https://user:password@example.org", MEILI_URL="https://example.org?api_key=query-canary")
|
||||
local.update(GITEA_URL="https://example.org#fragment-canary", MEILI_URL="https://token@example.org")
|
||||
for name, values in ((".env", env), ("config.local", local)):
|
||||
(self.deploy / name).write_text("\n".join(f"{key}={value}" for key, value in values.items()), encoding="utf-8")
|
||||
config = self.agent.config()
|
||||
for value in (*env.values(), *local.values()):
|
||||
self.assertNotIn(value, json.dumps(config))
|
||||
entries = {entry["key"]: entry for entry in config["entries"]}
|
||||
for key in secrets | {"GITEA_URL", "MEILI_URL"}:
|
||||
self.assertTrue(entries[key]["secret"])
|
||||
self.assertEqual("••••••••", entries[key]["effective"])
|
||||
self.assertEqual("15m", entries["ACCESS_TOKEN_TTL"]["effective"])
|
||||
|
||||
def test_plain_urls_remain_readable_and_local_credentials_are_masked_after_save(self):
|
||||
self.schema.append({**self.schema[0], "key": "MEILI_URL", "default_value": "https://example.org/search"})
|
||||
(self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8")
|
||||
entry = self.agent.config()["entries"][-1]
|
||||
self.assertFalse(entry["secret"])
|
||||
self.assertEqual("https://example.org/search", entry["effective"])
|
||||
with patch.object(self.agent, "command", return_value=""):
|
||||
config = self.agent.save_config({"revision": self.agent.revision(), "values": {"MEILI_URL": "https://user:save-canary@example.org"}})
|
||||
self.assertNotIn("save-canary", json.dumps(config))
|
||||
self.assertTrue(config["entries"][-1]["secret"])
|
||||
|
||||
def test_invalid_values_and_compose_failure_preserve_original(self):
|
||||
original = (self.deploy / "config.local").read_bytes()
|
||||
for values in ({"TRAIN_HOUR": "24"}, {"TRAIN_HOUR": "bad"}, {"UNKNOWN": "x"}, {"PIPU_DATA": "/tmp"}, {"LOG_LEVEL": "x\nOTHER=y"}):
|
||||
with self.assertRaises(ManagementError):
|
||||
self.agent.save_config({"revision": self.agent.revision(), "values": values})
|
||||
self.assertEqual(original, (self.deploy / "config.local").read_bytes())
|
||||
with patch.object(self.agent, "command", side_effect=subprocess.CalledProcessError(1, "docker")):
|
||||
with self.assertRaises(ManagementError):
|
||||
self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}})
|
||||
self.assertEqual(original, (self.deploy / "config.local").read_bytes())
|
||||
|
||||
def test_source_validation_rejects_shell_local_and_credentials(self):
|
||||
for url in ("/tmp/repo", "file:///tmp/repo", "ext::sh -c evil", "--upload-pack=evil", "https://user:secret@example.org/o/r", "https://secret@example.org/o/r", "https://example.org/o/r\narg", "ssh://root@example.org/o/r"):
|
||||
with self.subTest(url=url), self.assertRaises(ManagementError):
|
||||
agent_module.validate_url(url)
|
||||
for url in ("https://example.org/o/r", "http://10.0.0.1:3000/o/r", "git@example.org:o/r.git", "ssh://git@example.org:2222/o/r.git"):
|
||||
agent_module.validate_url(url)
|
||||
|
||||
def test_latest_is_semver_stable_and_pins_peeled_commit(self):
|
||||
refs = {"refs/tags/v1.9.0": "a" * 40, "refs/tags/v1.10.0": "b" * 40, "refs/tags/v1.10.0^{}": "c" * 40, "refs/tags/v2.0.0-rc.1": "d" * 40}
|
||||
with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job", return_value={}) as start:
|
||||
self.agent.handle("updates.start", {"source_id": "origin", "channel": "latest"})
|
||||
self.assertEqual("v1.10.0", start.call_args.args[1])
|
||||
self.assertEqual("c" * 40, start.call_args.args[3]["PIPU_UPDATE_COMMIT"])
|
||||
self.assertEqual("refs/tags/v1.10.0", start.call_args.args[3]["PIPU_UPDATE_REF"])
|
||||
with self.assertRaises(ManagementError):
|
||||
self.agent.handle("updates.start", {"source_id": "origin", "channel": "version", "version": "--evil"})
|
||||
|
||||
def test_main_and_default_source(self):
|
||||
with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job", return_value={}) as start:
|
||||
self.agent.handle("updates.start", {"source_id": "origin", "channel": "main"})
|
||||
self.assertEqual("main", start.call_args.args[1])
|
||||
result = self.agent.handle("updates.get", {"source_id": None})
|
||||
self.assertEqual("origin", result["sources"][0]["id"])
|
||||
|
||||
def test_running_job_blocks_mutations_and_survives_restart_as_failure(self):
|
||||
self.agent.job = {"state": "running"}
|
||||
for operation, body in (("updates.start", {"channel": "main"}), ("config.apply", {}), ("sources.save", {}), ("config.save", {})):
|
||||
with self.subTest(operation=operation), self.assertRaises(ManagementError) as raised:
|
||||
self.agent.handle(operation, body)
|
||||
self.assertEqual(409, raised.exception.status)
|
||||
self.agent.save("job.json", {"state": "running", "id": "old"})
|
||||
restored = Agent(self.deploy, self.mailbox, self.private)
|
||||
self.assertEqual("failed", restored.job["state"])
|
||||
|
||||
def test_completed_job_redacts_secrets_and_marks_applied_revision(self):
|
||||
job = {"id": "test", "kind": "config", "state": "running", "started_at": "", "finished_at": "", "target": "config.local", "log": ""}
|
||||
self.agent.job = job
|
||||
revision = self.agent.revision()
|
||||
self.agent.run_job(job, [sys.executable, "-c", "print('top-secret')"], {}, revision)
|
||||
self.assertEqual("succeeded", job["state"])
|
||||
self.assertNotIn("top-secret", job["log"])
|
||||
self.assertIn("[redacted]", job["log"])
|
||||
self.assertFalse(self.agent.config()["restart_required"])
|
||||
self.agent.run_job(job, [sys.executable, "-c", "raise SystemExit(2)"], {}, revision)
|
||||
self.assertEqual("failed", job["state"])
|
||||
|
||||
def test_mailbox_discards_stale_request_and_never_executes_it(self):
|
||||
path = self.mailbox / "00000000-0000-0000-0000-000000000001.request.json"
|
||||
path.write_text(json.dumps({"operation": "config.apply", "created_at": 1}), encoding="utf-8")
|
||||
with patch.object(self.agent, "handle") as handle:
|
||||
self.agent.tick()
|
||||
handle.assert_not_called()
|
||||
response = json.loads(path.with_name(path.name.replace("request", "response")).read_text(encoding="utf-8"))
|
||||
self.assertEqual("management.request_expired", response["code"])
|
||||
|
||||
|
||||
class ComposeTests(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('docker'), 'Docker Compose CLI unavailable')
|
||||
def test_real_compose_priorities_and_literal_secret_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / '.env').write_text('POSTGRES_PASSWORD=test-db\nINTERNAL_TOKEN=test-internal\nMEILI_MASTER_KEY=test-meili\nLOG_LEVEL=warn\n', encoding='utf-8')
|
||||
secret = "literal$VALUE'with\\slash#hash"
|
||||
(root / 'config.local').write_text("LOG_LEVEL='debug'\nTRAIN_HOUR='7'\nGITEA_TOKEN='" + secret.replace("'", "\\'") + "'\n", encoding='utf-8')
|
||||
compose = Path(__file__).with_name('compose.yaml').resolve()
|
||||
result = subprocess.run(['docker', 'compose', '--project-directory', str(root), '-f', str(compose), '--env-file', str(root / 'config.local'), '--env-file', str(root / '.env'), 'config', '--format', 'json'], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(0, result.returncode, 'Docker Compose configuration failed: ' + result.stderr)
|
||||
services = json.loads(result.stdout)['services']
|
||||
self.assertEqual('warn', services['api']['environment']['LOG_LEVEL'])
|
||||
self.assertEqual('7', services['recommender']['environment']['TRAIN_HOUR'])
|
||||
self.assertEqual('7', services['analyzer']['environment']['TRAIN_HOUR'])
|
||||
# Canonical Compose output escapes dollars for reuse as YAML.
|
||||
self.assertEqual(secret.replace('$', '$$'), services['api']['environment']['GITEA_TOKEN'])
|
||||
self.assertNotIn('GITEA_TOKEN', services['recommender']['environment'])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+51
-6
@@ -14,6 +14,15 @@ fi
|
||||
trap 'rm -f "$0"' EXIT
|
||||
cd "$PIPU_DEPLOY_DIR"
|
||||
|
||||
# Share a lock with manual deployments and config application.
|
||||
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
|
||||
exec 9<.management.lock
|
||||
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
|
||||
|
||||
touch config.local
|
||||
chmod 600 config.local
|
||||
compose() { docker compose --env-file config.local --env-file .env "$@"; }
|
||||
|
||||
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
|
||||
|
||||
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
|
||||
@@ -69,7 +78,15 @@ if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [
|
||||
fi
|
||||
|
||||
REF="${1:-}"
|
||||
git fetch --tags --prune --quiet origin
|
||||
if [ -n "${PIPU_UPDATE_SOURCE:-}" ]; then
|
||||
[[ "${PIPU_UPDATE_COMMIT:-}" =~ ^[a-f0-9]{40,64}$ ]] || { echo 'Invalid commit' >&2; exit 1; }
|
||||
[[ "${PIPU_UPDATE_REF:-}" =~ ^refs/(heads/main|tags/v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?)$ ]] || { echo 'Invalid ref' >&2; exit 1; }
|
||||
git -c protocol.file.allow=never -c protocol.ext.allow=never fetch --quiet -- "$PIPU_UPDATE_SOURCE" "$PIPU_UPDATE_REF"
|
||||
FETCHED="$(git rev-parse 'FETCH_HEAD^{commit}')"
|
||||
[ "$FETCHED" = "$PIPU_UPDATE_COMMIT" ] || { echo 'Source changed; select update again' >&2; exit 1; }
|
||||
else
|
||||
git fetch --tags --prune --quiet origin
|
||||
fi
|
||||
|
||||
if [ -z "$REF" ]; then
|
||||
REF="$(git tag -l 'v*' --sort=-v:refname | grep -v -- '-rc' | head -1)"
|
||||
@@ -80,6 +97,7 @@ fi
|
||||
|
||||
# Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему.
|
||||
CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)"
|
||||
CURRENT_COMMIT="$(sed -n 's/^PIPU_COMMIT=//p' .env)"
|
||||
TARGET="${REF#v}"
|
||||
core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; }
|
||||
cur_core="$(core "$CURRENT" || true)"
|
||||
@@ -96,26 +114,53 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
|
||||
fi
|
||||
|
||||
echo "→ переключаемся на $REF"
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||
# Refuse to overwrite local source changes.
|
||||
git diff --quiet && git diff --cached --quiet || { echo 'Repository has local changes' >&2; exit 1; }
|
||||
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
|
||||
git checkout --quiet --detach "$PIPU_UPDATE_COMMIT"
|
||||
elif git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||
git checkout --quiet "$REF"
|
||||
git merge --ff-only --quiet "origin/$REF"
|
||||
else
|
||||
git checkout --quiet --detach "$REF"
|
||||
fi
|
||||
|
||||
VERSION="$(git describe --tags --always --dirty)"
|
||||
# Use the target revision's setup, even when this updater was copied before
|
||||
# checkout. Existing deployments must gain new host services during upgrades.
|
||||
if [ -f ./install-management.sh ]; then
|
||||
bash ./install-management.sh --ensure
|
||||
fi
|
||||
|
||||
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
|
||||
if [ "$REF" = main ]; then VERSION="main-$(git rev-parse --short HEAD)"; else VERSION="$REF"; fi
|
||||
else
|
||||
VERSION="$(git describe --tags --always --dirty)"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
COMMIT="$(git rev-parse HEAD)"
|
||||
if grep -q '^PIPU_COMMIT=' .env; then
|
||||
sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$COMMIT|" .env
|
||||
else
|
||||
echo "PIPU_COMMIT=$COMMIT" >> .env
|
||||
fi
|
||||
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
|
||||
echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)"
|
||||
|
||||
docker compose build --pull
|
||||
docker compose up -d --remove-orphans
|
||||
compose config --quiet
|
||||
if ! compose build --pull; then
|
||||
# A build failure leaves running containers untouched; keep their version in .env.
|
||||
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$CURRENT|" .env
|
||||
sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$CURRENT_COMMIT|" .env
|
||||
echo 'Build failed; running containers were not replaced' >&2
|
||||
exit 1
|
||||
fi
|
||||
compose up -d --remove-orphans
|
||||
docker image prune -f >/dev/null
|
||||
|
||||
echo "→ ждём готовности api"
|
||||
for _ in $(seq 1 60); do
|
||||
# 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4
|
||||
if docker compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
|
||||
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
|
||||
echo "pipu $VERSION запущен"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -17,6 +17,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
||||
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
|
||||
| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда |
|
||||
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
|
||||
| [social.md](social.md) | Друзья, заявки, личные сообщения, уведомления и отправка музыки |
|
||||
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
||||
| [web.md](web.md) | Веб-клиент |
|
||||
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
|
||||
@@ -24,6 +25,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
||||
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
||||
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
||||
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
|
||||
| [server-management.md](server-management.md) | Управление обновлениями и config.local из админки, установка агента |
|
||||
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
|
||||
|
||||
## Стек в одном абзаце
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
|
||||
## 2. Пользователи и роли
|
||||
|
||||
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
|
||||
|
||||
- **Медиатека:** библиотека и хранилища.
|
||||
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
|
||||
- **Система:** обновления, конфигурация, настройки и журнал.
|
||||
|
||||
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
|
||||
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
|
||||
|
||||
«Администрирование → Пользователи».
|
||||
|
||||
| Роль | Что может |
|
||||
@@ -133,3 +142,8 @@ APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релиз
|
||||
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
|
||||
|
||||
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
|
||||
|
||||
|
||||
## Управление обновлениями и конфигурацией
|
||||
|
||||
Разделы **Обновления** и **Конфигурация** используют агент на хосте. Установка для существующего сервера, права, приоритет `.env` над `config.local` и диагностика описаны в [server-management.md](server-management.md).
|
||||
@@ -96,6 +96,7 @@ pipu-music/
|
||||
│ │ ├── internal/
|
||||
│ │ │ ├── auth/ library/ search/ genius/
|
||||
│ │ │ ├── playback/ wave/ uploads/ appupdate/ system/
|
||||
│ │ │ ├── social/ # друзья, личные сообщения, центр уведомлений
|
||||
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
|
||||
│ │ └── migrations/ # goose SQL-миграции; SQL-запросы — в адаптерах internal/
|
||||
│ └── recommender/ # Python (uv)
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user