Clarify receive requests and add retry countdowns and device unblock controls
This commit is contained in:
@@ -65,6 +65,7 @@ import {
|
||||
items,
|
||||
spaces,
|
||||
error,
|
||||
transferCooldown,
|
||||
bootstrap,
|
||||
api,
|
||||
refresh,
|
||||
@@ -94,6 +95,35 @@ const tab = ref('devices'),
|
||||
invite = ref(null),
|
||||
qr = ref(''),
|
||||
now = ref(Date.now());
|
||||
const blockedDevices = ref([]),
|
||||
confirmBlockId = ref(null);
|
||||
const retrySeconds = computed(() =>
|
||||
transferCooldown.value.targetDeviceId &&
|
||||
transferCooldown.value.targetDeviceId !== selected.value?.id
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.ceil((transferCooldown.value.until - Math.max(now.value, Date.now())) / 1000),
|
||||
),
|
||||
);
|
||||
const modalTitle = computed(() =>
|
||||
modal.value === 'connect'
|
||||
? connectTitle.value
|
||||
: modal.value === 'about'
|
||||
? '서비스 정보'
|
||||
: modal.value === 'blocks'
|
||||
? '차단한 기기'
|
||||
: '내 기기 설정',
|
||||
);
|
||||
function contentLabel(kind) {
|
||||
return kind === 'file' ? '파일' : kind === 'text' ? '텍스트' : '링크';
|
||||
}
|
||||
function transferSize(bytes) {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
if (bytes >= 1e5) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
if (bytes >= 1000) return `${(bytes / 1000).toFixed(1)} KB`;
|
||||
return '1 KB 미만';
|
||||
}
|
||||
const normalizedJoinCode = computed(() => joinCode.value.replace(/[\s-]/g, '').toUpperCase());
|
||||
const validJoinCode = computed(() =>
|
||||
/^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/.test(normalizedJoinCode.value),
|
||||
@@ -186,6 +216,10 @@ async function receiveAction(r, action) {
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestAction(r.id, action);
|
||||
if (action === 'block') {
|
||||
confirmBlockId.value = null;
|
||||
notify('기기를 차단했어요. 내 기기 설정에서 해제할 수 있어요.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (action === 'accept') receiver.abort(r.id);
|
||||
throw error;
|
||||
@@ -563,6 +597,20 @@ async function openDeviceSettings() {
|
||||
if (!matchMedia('(max-width: 639px)').matches)
|
||||
document.querySelector('.device-settings-form input')?.focus();
|
||||
}
|
||||
async function openBlockedDevices() {
|
||||
await act(async () => {
|
||||
blockedDevices.value = (await api('/device/blocks')).blocks;
|
||||
modal.value = 'blocks';
|
||||
});
|
||||
}
|
||||
async function unblockDevice(peer) {
|
||||
await act(async () => {
|
||||
await api(`/device/blocks/${peer.deviceId}`, 'DELETE');
|
||||
blockedDevices.value = (await api('/device/blocks')).blocks;
|
||||
await refresh();
|
||||
notify('차단을 해제했어요.');
|
||||
});
|
||||
}
|
||||
async function saveName() {
|
||||
await act(async () => {
|
||||
await patchDevice({ name: nameDraft.value });
|
||||
@@ -879,20 +927,37 @@ async function spaceAction(space, action, memberId) {
|
||||
aria-label="전송 수락 요청"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="incoming-icon"><ArrowDownLeft :size="22" /></div>
|
||||
<div class="incoming-info">
|
||||
<strong
|
||||
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
|
||||
>
|
||||
<p>
|
||||
{{ r.kind === 'file' ? '파일' : r.kind === 'text' ? '텍스트' : '링크' }} ·
|
||||
{{ r.count }}개 · {{ r.byteSize.toLocaleString() }}바이트
|
||||
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
|
||||
</p>
|
||||
<div class="consent-heading">
|
||||
<div class="incoming-icon" aria-hidden="true">
|
||||
<FileIcon v-if="r.kind === 'file'" :size="28" /><ClipboardPaste
|
||||
v-else-if="r.kind === 'text'"
|
||||
:size="28"
|
||||
/><Link v-else :size="28" />
|
||||
</div>
|
||||
<div class="incoming-info">
|
||||
<h2 class="consent-kind">{{ contentLabel(r.kind) }} 전송 요청</h2>
|
||||
<strong
|
||||
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
|
||||
>
|
||||
<p>
|
||||
<template v-if="r.kind === 'file'"
|
||||
>{{ r.count }}개 ·
|
||||
<template v-if="r.count > 1"
|
||||
>총 {{ transferSize(r.byteSize) }} ·
|
||||
</template></template
|
||||
>
|
||||
<span
|
||||
>{{
|
||||
Math.max(0, Math.ceil((r.expiresAt - Math.max(now, Date.now())) / 1000))
|
||||
}}초 남음</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="r.files" class="request-files">
|
||||
<li v-for="file in r.files" :key="file.id">
|
||||
{{ file.name }} · {{ size(file.size) }}
|
||||
<span class="request-file-name">{{ file.name }}</span
|
||||
><span class="request-file-size">{{ transferSize(file.size) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="incoming-actions">
|
||||
@@ -903,16 +968,31 @@ async function spaceAction(space, action, memberId) {
|
||||
>
|
||||
<Check :size="16" /> 수락</button
|
||||
><button class="button small" :disabled="busy" @click="receiveAction(r, 'reject')">
|
||||
거절</button
|
||||
><button
|
||||
class="text-button muted"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'block')"
|
||||
>
|
||||
차단
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
<div class="consent-footer">
|
||||
<template v-if="confirmBlockId === r.id"
|
||||
><span>이 기기를 차단할까요?</span
|
||||
><button class="text-button" :disabled="busy" @click="confirmBlockId = null">
|
||||
취소</button
|
||||
><button
|
||||
class="text-button block-confirm"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'block')"
|
||||
>
|
||||
차단하기
|
||||
</button></template
|
||||
>
|
||||
<button
|
||||
v-else
|
||||
class="text-button block-device"
|
||||
:disabled="busy"
|
||||
@click="confirmBlockId = r.id"
|
||||
>
|
||||
이 기기 차단
|
||||
</button>
|
||||
</div>
|
||||
<p class="request-note">기기 이름은 신원이 인증된 표시가 아닙니다.</p>
|
||||
</article>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -1115,6 +1195,7 @@ async function spaceAction(space, action, memberId) {
|
||||
:clipboard-key="selectionEpoch"
|
||||
:read-clipboard-on-selection="true"
|
||||
:busy="busy"
|
||||
:retry-seconds="retrySeconds"
|
||||
:transfer="currentTransfer"
|
||||
:progress="
|
||||
Object.entries(visibleUploadProgress)
|
||||
@@ -1147,14 +1228,7 @@ async function spaceAction(space, action, memberId) {
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
|
||||
<section
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="
|
||||
modal === 'connect' ? connectTitle : modal === 'about' ? '서비스 정보' : '설정'
|
||||
"
|
||||
>
|
||||
<section class="modal" role="dialog" aria-modal="true" :aria-label="modalTitle">
|
||||
<header>
|
||||
<div class="connect-dialog-title">
|
||||
<button
|
||||
@@ -1167,13 +1241,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h2>
|
||||
{{
|
||||
modal === 'connect'
|
||||
? connectTitle
|
||||
: modal === 'about'
|
||||
? '서비스 정보'
|
||||
: '내 기기 설정'
|
||||
}}
|
||||
{{ modalTitle }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
|
||||
@@ -1278,6 +1346,21 @@ async function spaceAction(space, action, memberId) {
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="modal === 'blocks'">
|
||||
<p v-if="!blockedDevices.length" class="empty-blocks">차단한 기기가 없어요.</p>
|
||||
<ul v-else class="blocked-device-list">
|
||||
<li v-for="peer in blockedDevices" :key="peer.deviceId">
|
||||
<div>
|
||||
<strong>{{ peer.name }}</strong
|
||||
><small>#{{ peer.code }}</small>
|
||||
</div>
|
||||
<button class="button small" :disabled="busy" @click="unblockDevice(peer)">
|
||||
차단 해제
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="text-button" @click="modal = 'device'">내 기기 설정으로 돌아가기</button>
|
||||
</template>
|
||||
<template v-else
|
||||
><form class="name-form device-settings-form" @submit.prevent="saveName">
|
||||
<label>
|
||||
@@ -1296,6 +1379,10 @@ async function spaceAction(space, action, memberId) {
|
||||
/> </label
|
||||
><button class="button primary" :disabled="busy" aria-label="저장">저장</button>
|
||||
</form>
|
||||
<div class="device-blocks-setting">
|
||||
<span>차단한 기기</span
|
||||
><button class="button small" :disabled="busy" @click="openBlockedDevices">관리</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ const props = defineProps({
|
||||
targetKey: String,
|
||||
clipboardKey: Number,
|
||||
busy: Boolean,
|
||||
retrySeconds: { type: Number, default: 0 },
|
||||
disabled: Boolean,
|
||||
transfer: Object,
|
||||
progress: Array,
|
||||
@@ -290,7 +291,7 @@ function submit() {
|
||||
emit('cancel');
|
||||
return;
|
||||
}
|
||||
if (!ready.value || !mutable.value) return;
|
||||
if (!ready.value || !mutable.value || props.retrySeconds > 0) return;
|
||||
if (source.value === 'url')
|
||||
emit(
|
||||
'clipboard',
|
||||
@@ -514,10 +515,21 @@ function size(value) {
|
||||
<button
|
||||
class="button primary content-submit"
|
||||
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
|
||||
:disabled="busy || disabled || ((!transfer || retryable) && !ready)"
|
||||
:disabled="
|
||||
busy || disabled || (!transfer && retrySeconds > 0) || ((!transfer || retryable) && !ready)
|
||||
"
|
||||
@click="submit"
|
||||
>
|
||||
{{ retryable ? '다시 전송' : transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
|
||||
{{
|
||||
!transfer && retrySeconds > 0
|
||||
? `${retrySeconds}초 후 전송 가능`
|
||||
: retryable
|
||||
? '다시 전송'
|
||||
: transfer
|
||||
? '전송 취소'
|
||||
: saveMode
|
||||
? '저장하기'
|
||||
: '전송하기'
|
||||
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="retryable" class="content-switches">
|
||||
|
||||
@@ -26,6 +26,7 @@ export const device = ref(null),
|
||||
discoveryEnabled = ref(false),
|
||||
error = ref('');
|
||||
export const pendingPeers = ref([]);
|
||||
export const transferCooldown = ref({ until: 0, targetDeviceId: null });
|
||||
let pendingTimer;
|
||||
let deviceInfo = {};
|
||||
export const nextCursor = ref(null);
|
||||
@@ -60,11 +61,28 @@ export async function api(path, method = 'GET', body, key, signal) {
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok)
|
||||
if (!response.ok) {
|
||||
const retryAfterSeconds = Number(
|
||||
payload.retryAfterSeconds ?? response.headers.get('Retry-After'),
|
||||
);
|
||||
if (
|
||||
response.status === 429 &&
|
||||
retryAfterSeconds > 0 &&
|
||||
Number.isFinite(retryAfterSeconds) &&
|
||||
method === 'POST' &&
|
||||
(path === '/transfer-requests' || /^\/vault\/[^/]+\/send$/.test(path))
|
||||
) {
|
||||
transferCooldown.value = {
|
||||
until: Date.now() + Math.ceil(retryAfterSeconds) * 1000,
|
||||
targetDeviceId: payload.limitScope === 'pair' ? body?.targetDeviceId : null,
|
||||
};
|
||||
}
|
||||
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
|
||||
status: response.status,
|
||||
code: payload.code,
|
||||
retryAfterSeconds,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
export async function bootstrap() {
|
||||
@@ -154,9 +172,13 @@ export function showError(err) {
|
||||
const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw)
|
||||
? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'
|
||||
: raw;
|
||||
error.value = message;
|
||||
const displayed =
|
||||
err?.retryAfterSeconds > 0
|
||||
? `${message} ${Math.ceil(err.retryAfterSeconds)}초 후 다시 시도할 수 있어요.`
|
||||
: message;
|
||||
error.value = displayed;
|
||||
errorTimer = setTimeout(() => {
|
||||
if (error.value === message) error.value = '';
|
||||
if (error.value === displayed) error.value = '';
|
||||
}, 5000);
|
||||
}
|
||||
export const refresh = createRefreshQueue(async () => {
|
||||
|
||||
@@ -2419,6 +2419,123 @@ a.button {
|
||||
.consent-card .text-button {
|
||||
color: #666;
|
||||
}
|
||||
.consent-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
.consent-card .incoming-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
}
|
||||
.consent-kind {
|
||||
font-size: 22px;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.consent-card .incoming-info strong {
|
||||
font-size: 15px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.consent-card .incoming-info small {
|
||||
font-size: 12px;
|
||||
}
|
||||
.consent-card .incoming-info p {
|
||||
font-size: 12px;
|
||||
}
|
||||
.consent-card .request-files {
|
||||
width: 100%;
|
||||
margin: 6px 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 160px;
|
||||
}
|
||||
.consent-card .request-files li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.request-file-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.request-file-size {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.consent-card .incoming-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
padding: 0;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.consent-card .incoming-actions .button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
font-size: 14px;
|
||||
justify-content: center;
|
||||
}
|
||||
.consent-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.consent-footer .text-button {
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
min-height: 44px;
|
||||
}
|
||||
.consent-footer .block-confirm {
|
||||
color: #a33;
|
||||
}
|
||||
.device-blocks-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.blocked-device-list {
|
||||
margin: 0 0 20px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.blocked-device-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.blocked-device-list li > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.blocked-device-list strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.blocked-device-list small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-blocks {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.composer-transfer-state {
|
||||
margin: 16px 0;
|
||||
font-size: 13px;
|
||||
|
||||
Reference in New Issue
Block a user