Add plain text clipboard transfers and refine input feedback

This commit is contained in:
2026-10-13 14:07:03 +09:00
parent af004a2942
commit 8a8df417aa
32 changed files with 1263 additions and 197 deletions

View File

@@ -33,6 +33,7 @@ import {
clearAccountUploads,
cancelTransfer,
uploadProgress,
clipboardTextLimitBytes,
} from './client';
const props = defineProps({ initialView: { type: String, default: 'vault' } });
const emit = defineEmits(['devices', 'send-item']);
@@ -248,7 +249,7 @@ async function save(values) {
'/vault',
'POST',
{
kind: values.files ? 'file' : 'url',
kind: values.files ? 'file' : values.text !== undefined ? 'text' : 'url',
...(values.files
? {
files: values.files.map((file) => ({
@@ -257,7 +258,9 @@ async function save(values) {
type: file.type || 'application/octet-stream',
})),
}
: { urls: values.urls }),
: values.text !== undefined
? { text: values.text }
: { urls: values.urls }),
note: note.value,
},
crypto.randomUUID(),
@@ -317,7 +320,7 @@ function recordLinkOpen(item) {
}
async function copy(item) {
await act(async () => {
await navigator.clipboard.writeText(item.url);
await navigator.clipboard.writeText(item.kind === 'text' ? item.text : item.url);
await read(item);
message('복사했습니다.');
});
@@ -577,7 +580,7 @@ async function resendVerification() {
<input
type="search"
v-model="search"
placeholder="링크, 파일, 메모 검색"
placeholder="링크, 텍스트, 파일, 메모 검색"
aria-label="계정 자료 검색"
/><button class="button small">검색</button>
</form>
@@ -592,8 +595,8 @@ async function resendVerification() {
type="checkbox"
v-model="selected"
:value="item.accessId"
:aria-label="`${item.file?.name ?? item.url} 선택`"
/><span>{{ item.file ? '파일' : '링크' }}</span
:aria-label="`${item.file?.name ?? item.url ?? item.text} 선택`"
/><span>{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}</span
><span v-if="view === 'inbox'">{{ item.peerName }}</span
><time>{{ date(item.createdAt) }}</time>
</div>
@@ -605,6 +608,9 @@ async function resendVerification() {
><span>{{ size(item.file.size) }}</span>
</div>
</div>
<pre v-else-if="item.kind === 'text'" class="item-body account-text-preview">{{
item.text
}}</pre>
<p v-else class="item-body">{{ item.url }}</p>
<form v-if="editing === item.accessId" class="note-editor" @submit.prevent="saveNote(item)">
<input v-model="noteDraft" maxlength="200" aria-label="한 줄 메모 수정" /><button
@@ -624,6 +630,7 @@ async function resendVerification() {
><template v-else
><button class="button small" @click="copy(item)"><Copy :size="14" />복사</button
><a
v-if="item.url"
class="button small"
:href="item.url"
target="_blank"
@@ -687,7 +694,8 @@ async function resendVerification() {
</button>
</header>
<div v-if="!saveKind" class="save-kind-choices">
<button class="button" @click="saveKind = 'url'"><Link :size="22" />링크 저장</button
<button class="button" @click="saveKind = 'clipboard'">
<Copy :size="22" />클립보드 저장</button
><button class="button" @click="saveKind = 'file'"><FileIcon :size="22" />파일 저장</button>
</div>
<template v-else>
@@ -711,7 +719,8 @@ async function resendVerification() {
.filter(([key]) => key.startsWith(saveTransfer?.id + ':'))
.map(([, value]) => value)
"
@links="save({ urls: $event })"
@clipboard="save($event)"
:text-limit-bytes="clipboardTextLimitBytes"
@files="save({ files: $event })"
@cancel="cancelSave"
/>

View File

@@ -14,6 +14,7 @@ import {
Check,
X,
Copy,
ClipboardPaste,
Settings,
Sun,
Moon,
@@ -37,6 +38,7 @@ import {
} from '@lucide/vue';
import QRCode from 'qrcode';
import { version as appVersion } from '../package.json';
import { DEVICE_NAME_MAX_LENGTH } from '@hop/shared';
import AccountPanel from './AccountPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
@@ -52,6 +54,7 @@ import {
connected,
spaceInviteCodeLength,
locationSettings,
clipboardTextLimitBytes,
publishLocation,
clearLocation,
reserveLocationAcceptance,
@@ -144,6 +147,31 @@ createNearbySearch({
});
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
const blockedLinks = receiver.blockedLinks;
const receivedTexts = receiver.receivedTexts;
const receivedLinks = receiver.receivedLinks;
watch(
() => receivedTexts.value[0]?.accessId,
async (id) => {
if (!id) return;
await nextTick();
document.querySelector('.received-text-dialog > .button')?.focus();
},
);
function trapReceivedFocus(event) {
const controls = [
...event.currentTarget.querySelectorAll('button:not(:disabled), [tabindex="0"]'),
];
const first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
watch([requests, items], ([currentRequests, currentItems]) => {
receiver.reconcile(currentRequests, currentItems);
});
@@ -533,14 +561,34 @@ function selectPeer(peer) {
selectionEpoch.value++;
forwardedItem.value = null;
}
async function submitLinks(urls) {
async function submitClipboard(content) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
forwardedItem.value = null;
itemToSend.value = null;
await send(selected.value.id, 'url', { urls });
await send(
selected.value.id,
content.kind,
content.kind === 'text' ? { text: content.text } : { urls: content.urls },
);
});
}
async function copyReceived(item) {
await act(async () => {
try {
if (!navigator.clipboard?.writeText) throw Error('unavailable');
await navigator.clipboard.writeText(item.kind === 'text' ? item.text : item.url);
} catch {
throw Error('복사하지 못했어요. 내용을 선택해 직접 복사해 주세요.');
}
notify('클립보드에 복사했어요.');
await markRead(item);
});
}
async function closeReceivedText(item) {
receivedTexts.value = receivedTexts.value.filter((value) => value.accessId !== item.accessId);
await act(() => markRead(item));
}
async function submitFiles(files) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
@@ -736,8 +784,8 @@ async function spaceAction(space, action, memberId) {
target="_blank"
rel="noopener noreferrer"
@click="receiver.opened(item)"
>링크 열기 <ArrowUpRight :size="15"
/></a>
>링크 열기 <ArrowUpRight :size="15" /></a
><button class="button small" @click="copyReceived(item)">주소 복사</button>
<button
class="icon-button"
aria-label="안내 닫기"
@@ -747,6 +795,53 @@ async function spaceAction(space, action, memberId) {
</button>
</article>
</section>
<div
v-if="receivedTexts.length"
class="receive-backdrop text-receive-backdrop"
@keydown.esc="closeReceivedText(receivedTexts[0])"
>
<section
class="received-text-dialog"
role="dialog"
aria-modal="true"
aria-label="받은 텍스트"
@keydown.tab="trapReceivedFocus"
>
<header>
<h2>
<ClipboardPaste :size="20" />{{ receivedTexts[0].peerName }}에서 텍스트를 보냈어요
</h2>
<button
class="icon-button"
aria-label="받은 텍스트 닫기"
@click="closeReceivedText(receivedTexts[0])"
>
<X :size="20" />
</button>
</header>
<pre class="received-text-preview" tabindex="0" aria-label="받은 텍스트 내용">{{
receivedTexts[0].text
}}</pre>
<button
class="button primary"
:disabled="busy"
@click="copyReceived(receivedTexts[0])"
>
<Copy :size="18" />클립보드에 복사
</button>
</section>
</div>
<div v-if="receivedLinks.length" class="received-link-notice" role="status">
<Link :size="18" /><span>링크를 열었어요.</span
><button class="text-button" @click="copyReceived(receivedLinks[0])">주소 복사</button
><button
class="icon-button"
aria-label="링크 수신 안내 닫기"
@click="receivedLinks = []"
>
<X :size="16" />
</button>
</div>
<div v-if="incoming.length" class="receive-backdrop">
<article
v-for="r in incoming.slice(0, 1)"
@@ -763,8 +858,8 @@ async function spaceAction(space, action, memberId) {
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
>
<p>
{{ r.kind === 'file' ? '파일' : '링크' }} · {{ r.count }}개 ·
{{ r.byteSize.toLocaleString() }}바이트
{{ r.kind === 'file' ? '파일' : r.kind === 'text' ? '텍스트' : '링크' }} ·
{{ r.count }}개 · {{ r.byteSize.toLocaleString() }}바이트
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
</p>
</div>
@@ -1011,7 +1106,8 @@ async function spaceAction(space, action, memberId) {
:disabled="!connected"
@cancel="cancelCurrentTransfer"
@files="submitFiles"
@links="submitLinks"
@clipboard="submitClipboard"
:text-limit-bytes="clipboardTextLimitBytes"
:transfer-source="forwardedItem"
:initial-item="itemToSend"
:vault-enabled="Boolean(account)"
@@ -1166,12 +1262,20 @@ async function spaceAction(space, action, memberId) {
</template>
<template v-else
><form class="name-form device-settings-form" @submit.prevent="saveName">
<label
>기기 이름<input
<label>
<span class="name-field-heading"
><span>기기 이름</span
><small id="device-name-count" class="name-count"
>{{ nameDraft.length }}/{{ DEVICE_NAME_MAX_LENGTH }}</small
></span
>
<input
v-model="nameDraft"
aria-label="기기 이름"
maxlength="40"
required /></label
aria-describedby="device-name-count"
:maxlength="DEVICE_NAME_MAX_LENGTH"
required
/> </label
><button class="button primary" :disabled="busy" aria-label="저장">저장</button>
</form>
<label class="settings-row discovery-setting">

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue';
import { ref, computed, watch, nextTick } from 'vue';
import {
FileUp,
File as FileIcon,
@@ -11,9 +11,11 @@ import {
Check,
ArrowUpRight,
} from '@lucide/vue';
import { parseUrls } from '@hop/shared';
import { classifyClipboard, clipboardCharacterCount } from '@hop/shared';
import { mayReadClipboardAutomatically } from './clipboardAccess';
const props = defineProps({
targetName: String,
textLimitBytes: { type: Number, default: 262144 },
targetKey: String,
clipboardKey: Number,
busy: Boolean,
@@ -30,20 +32,28 @@ const props = defineProps({
initialItem: Object,
vaultOwner: String,
});
const emit = defineEmits(['files', 'links', 'vault', 'vault-open', 'vault-more', 'cancel']);
const source = ref('file'),
const emit = defineEmits(['files', 'clipboard', 'vault', 'vault-open', 'vault-more', 'cancel']);
const source = ref('url'),
files = ref([]),
urlInput = ref(''),
clipboardManaged = ref(false),
reading = ref(false),
feedback = ref(''),
selectedVault = ref(null),
fileInput = ref(null);
fileInput = ref(null),
editor = ref(null),
compositionKind = ref(null);
let generation = 0;
const parsed = computed(() => parseUrls(urlInput.value));
const validUrl = computed(
const clipboardOnly = computed(() => ['url', 'clipboard'].includes(props.contentMode));
const classified = computed(() => classifyClipboard(urlInput.value));
const editorKind = computed(() => compositionKind.value ?? classified.value.kind);
const contentBytes = computed(() => new TextEncoder().encode(urlInput.value).length);
const characters = computed(() => clipboardCharacterCount(urlInput.value));
const validClipboard = computed(
() =>
parsed.value.urls.length > 0 && parsed.value.urls.length <= 20 && !parsed.value.invalid.length,
Boolean(urlInput.value.trim()) &&
((classified.value.kind === 'url' && classified.value.url.length <= 8192) ||
(classified.value.kind === 'text' && contentBytes.value <= props.textLimitBytes)),
);
const retryable = computed(
() =>
@@ -57,14 +67,15 @@ const mutable = computed(
);
const ready = computed(() =>
source.value === 'url'
? validUrl.value && !reading.value
? validClipboard.value && !reading.value && !compositionKind.value
: source.value === 'file'
? files.value.length > 0
: Boolean(selectedVault.value),
);
async function loadClipboard(force = false) {
async function loadClipboard(force = false, explicit = false) {
if (
!props.targetKey ||
(!explicit && reading.value) ||
props.transfer ||
!props.readClipboardOnSelection ||
props.contentMode === 'file' ||
@@ -81,23 +92,23 @@ async function loadClipboard(force = false) {
reading.value = true;
feedback.value = '';
try {
if (!explicit && !(await mayReadClipboardAutomatically())) return;
if (current !== generation || !mutable.value || document.visibilityState === 'hidden') return;
if (!navigator.clipboard?.readText) throw Error('Unavailable');
const result = parseUrls(await navigator.clipboard.readText());
if (current !== generation) return;
if (result.urls.length && result.urls.length <= 20 && !result.invalid.length) {
const result = await navigator.clipboard.readText();
if (current !== generation || !mutable.value) return;
if (result.trim()) {
source.value = 'url';
files.value = [];
selectedVault.value = null;
urlInput.value = result.urls.join('\n');
urlInput.value = result;
clipboardManaged.value = true;
} else if (force || clipboardManaged.value) {
urlInput.value = '';
clipboardManaged.value = false;
if (source.value === 'url') feedback.value = '클립보드에 링크가 없어요. 직접 입력해 주세요.';
} else if (explicit) {
feedback.value = '클립보드가 비어 있어요. 직접 입력하거나 붙여넣어 주세요.';
}
} catch {
if (current === generation && source.value === 'url')
feedback.value = '링크를 직접 붙여넣어 주세요.';
feedback.value = '클립보드를 읽지 못했어요. 직접 입력하거나 붙여넣어 주세요.';
} finally {
if (current === generation) reading.value = false;
}
@@ -113,12 +124,13 @@ watch(
selectedVault.value = null;
clipboardManaged.value = false;
feedback.value = '';
source.value = props.contentMode === 'url' ? 'url' : 'file';
source.value = props.contentMode === 'file' ? 'file' : 'url';
}
if (props.initialItem) {
source.value = 'vault';
selectedVault.value = props.initialItem;
} else void loadClipboard(true);
} else if (current[0] !== previous?.[0] || current[1] !== previous?.[1])
void loadClipboard(true);
},
{ immediate: true },
);
@@ -175,23 +187,6 @@ watch(
}
},
);
watch(
() => props.disabled,
(disabled) => {
if (!disabled) void loadClipboard();
},
);
const focus = () => void loadClipboard();
const visible = () => {
if (document.visibilityState === 'visible') focus();
};
window.addEventListener('focus', focus);
document.addEventListener('visibilitychange', visible);
onUnmounted(() => {
generation++;
window.removeEventListener('focus', focus);
document.removeEventListener('visibilitychange', visible);
});
function editUrl() {
generation++;
reading.value = false;
@@ -199,7 +194,7 @@ function editUrl() {
feedback.value = '';
}
function choose(list) {
if (!mutable.value || props.contentMode === 'url' || !list?.length) return;
if (!mutable.value || clipboardOnly.value || !list?.length) return;
generation++;
reading.value = false;
files.value = Array.from(list);
@@ -224,7 +219,7 @@ function toLink() {
urlInput.value = '';
source.value = 'url';
clipboardManaged.value = false;
void loadClipboard(true);
void loadClipboard(true, true);
}
function toVault() {
if (!mutable.value || !props.vaultEnabled) return;
@@ -247,15 +242,48 @@ function pasted(event) {
return;
}
if (props.contentMode === 'file' || props.transfer) return;
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
if (result.urls.length && !result.invalid.length) {
event.preventDefault();
editUrl();
files.value = [];
selectedVault.value = null;
source.value = 'url';
urlInput.value = result.urls.join('\n');
}
const value = event.clipboardData?.getData('text/plain');
if (value === undefined) return;
event.preventDefault();
editUrl();
files.value = [];
selectedVault.value = null;
source.value = 'url';
if (event.target === editor.value) {
const start = event.target.selectionStart ?? urlInput.value.length;
const end = event.target.selectionEnd ?? start;
urlInput.value = urlInput.value.slice(0, start) + value + urlInput.value.slice(end);
void nextTick(() => {
editor.value?.focus({ preventScroll: true });
editor.value?.setSelectionRange(start + value.length, start + value.length);
});
} else urlInput.value = value;
}
function edited(event) {
urlInput.value = event.target.value;
const focused = document.activeElement === event.target;
const start = event.target.selectionStart,
end = event.target.selectionEnd;
editUrl();
if (focused)
void nextTick(() => {
if (editor.value && editor.value !== event.target) {
editor.value.focus({ preventScroll: true });
editor.value.setSelectionRange(start, end);
}
});
}
function newline(event) {
if (event.target.tagName !== 'INPUT' || event.isComposing || compositionKind.value) return;
event.preventDefault();
const start = event.target.selectionStart ?? urlInput.value.length;
const end = event.target.selectionEnd ?? start;
urlInput.value = urlInput.value.slice(0, start) + '\n' + urlInput.value.slice(end);
editUrl();
void nextTick(() => {
editor.value?.focus({ preventScroll: true });
editor.value?.setSelectionRange(start + 1, start + 1);
});
}
function submit() {
if (props.transfer && !retryable.value) {
@@ -263,7 +291,13 @@ function submit() {
return;
}
if (!ready.value || !mutable.value) return;
if (source.value === 'url') emit('links', parsed.value.urls);
if (source.value === 'url')
emit(
'clipboard',
classified.value.kind === 'url'
? { kind: 'url', urls: [classified.value.url] }
: { kind: 'text', text: urlInput.value },
);
else if (source.value === 'file') emit('files', [...files.value]);
else emit('vault', selectedVault.value);
}
@@ -286,7 +320,7 @@ function size(value) {
@drop.stop.prevent="choose($event.dataTransfer?.files)"
>
<input
v-if="contentMode !== 'url'"
v-if="!clipboardOnly"
ref="fileInput"
type="file"
multiple
@@ -297,32 +331,63 @@ function size(value) {
/>
<div
class="content-pane"
:class="{ 'clipboard-pane': source === 'url' }"
@dragover.prevent
@drop.stop.prevent="choose($event.dataTransfer?.files)"
>
<template v-if="source === 'url'">
<div class="content-heading">
<strong><Link :size="18" />보낼 링크</strong
<strong
><Link v-if="editorKind === 'url'" :size="18" /><ClipboardPaste v-else :size="18" />{{
editorKind === 'url' ? '보낼 링크' : '보낼 텍스트'
}}</strong
><button
class="text-button clipboard-load"
:disabled="!mutable || reading"
@click="loadClipboard(true)"
@click="loadClipboard(true, true)"
>
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '클립보드 가져오기' }}
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '다시 가져오기' }}
</button>
</div>
<textarea
v-model="urlInput"
aria-label="보낼 URL"
placeholder="https://example.com"
rows="3"
<component
:is="editorKind === 'url' ? 'input' : 'textarea'"
ref="editor"
:value="urlInput"
:aria-label="editorKind === 'url' ? '보낼 URL' : '보낼 텍스트'"
:placeholder="
classified.kind === 'url' ? 'https://example.com' : '내용을 입력하거나 붙여넣어 주세요.'
"
:rows="editorKind === 'text' ? 6 : undefined"
:disabled="!mutable"
@input="editUrl"
class="current-url"
@input="edited"
@compositionstart="
compositionKind = classified.kind;
editUrl();
"
@compositionend="
compositionKind = null;
edited($event);
"
@keydown.enter="newline"
:class="editorKind === 'url' ? 'current-url' : 'current-text'"
/>
<small v-if="classified.kind === 'text'" class="clipboard-count"
>{{ characters.toLocaleString() }}자</small
>
<p v-if="feedback" class="content-feedback" role="status">{{ feedback }}</p>
<p v-else-if="urlInput.trim() && !validUrl" class="content-feedback" role="status">
HTTP 또는 HTTPS 링크를 입력해 주세요.
<p
v-if="classified.kind === 'url' && classified.url.length > 8192"
class="content-feedback"
role="status"
>
링크가 너무 길어요. 최대 8,192자까지 보낼 수 있어요.
</p>
<p
v-if="classified.kind === 'text' && contentBytes > textLimitBytes"
class="content-feedback"
role="status"
>
텍스트는 최대 {{ Math.floor(textLimitBytes / 1024) }}KiB까지 보낼 수 있어요.
</p>
</template>
<template v-else-if="source === 'file'">
@@ -370,11 +435,17 @@ function size(value) {
<strong><Inbox :size="18" />보관함에서 가져오기</strong>
</div>
<div v-if="selectedVault" class="current-vault">
<FileIcon v-if="selectedVault.file" :size="28" /><Link v-else :size="28" /><strong>{{
selectedVault.note || selectedVault.file?.name || selectedVault.url
<FileIcon v-if="selectedVault.file" :size="28" /><ClipboardPaste
v-else-if="selectedVault.kind === 'text'"
:size="28"
/><Link v-else :size="28" /><strong>{{
selectedVault.note ||
selectedVault.file?.name ||
selectedVault.url ||
selectedVault.text
}}</strong
><small v-if="selectedVault.note">{{
selectedVault.file?.name || selectedVault.url
selectedVault.file?.name || selectedVault.url || selectedVault.text
}}</small
><button
v-if="!transfer"
@@ -395,11 +466,14 @@ function size(value) {
:disabled="!mutable"
@click="selectVault(item)"
>
<FileIcon v-if="item.file" :size="18" /><Link v-else :size="18" /><span
><strong>{{ item.note || item.file?.name || item.url }}</strong
<FileIcon v-if="item.file" :size="18" /><ClipboardPaste
v-else-if="item.kind === 'text'"
:size="18"
/><Link v-else :size="18" /><span
><strong>{{ item.note || item.file?.name || item.url || item.text }}</strong
><small
>{{ item.role === 'self' ? '임시 저장' : '받은 항목' }} ·
{{ item.file ? '파일' : '링크' }}</small
{{ item.file ? '파일' : item.kind === 'text' ? '텍스트' : '링크' }}</small
></span
><Check v-if="selectedVault?.accessId === item.accessId" :size="16" />
</button>
@@ -453,7 +527,7 @@ function size(value) {
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
<FileIcon :size="14" />대신 파일 선택하기</button
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">
<Link :size="14" />대신 링크 붙여넣기</button
<Link :size="14" />대신 클립보드에서 가져오기</button
><button
v-if="vaultEnabled && source !== 'vault'"
class="text-button"

View File

@@ -28,6 +28,7 @@ export const pendingPeers = ref([]);
let pendingTimer;
let deviceInfo = {};
export const nextCursor = ref(null);
export const clipboardTextLimitBytes = ref(262144);
export const spaceInviteCodeLength = ref(4);
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
export async function publishLocation(position) {
@@ -147,6 +148,7 @@ export const refresh = createRefreshQueue(async () => {
const snapshot = await api('/device/snapshot');
device.value = snapshot.device;
peers.value = snapshot.devices;
clipboardTextLimitBytes.value = snapshot.clipboardTextLimitBytes ?? 262144;
pendingPeers.value = pendingPeers.value.filter(
(id) => !snapshot.devices.some((peer) => peer.id === id),
);
@@ -182,7 +184,7 @@ export const refresh = createRefreshQueue(async () => {
}
});
export async function send(targetDeviceId, kind, values) {
const content = values.urls.join('\n');
const content = kind === 'text' ? values.text : values.urls.join('\n');
const { requestId } = await api(
'/transfer-requests',
'POST',
@@ -190,7 +192,7 @@ export async function send(targetDeviceId, kind, values) {
targetDeviceId,
kind,
byteSize: new TextEncoder().encode(content).length,
count: values.urls.length,
count: kind === 'text' ? 1 : values.urls.length,
},
crypto.randomUUID(),
);
@@ -204,7 +206,12 @@ async function deliver(id) {
if (!draft) return;
completing.add(id);
try {
await api(`/transfers/${id}/complete`, 'POST', { urls: draft.urls }, draft.key);
await api(
`/transfers/${id}/complete`,
'POST',
draft.text !== undefined ? { text: draft.text } : { urls: draft.urls },
draft.key,
);
drafts.delete(id);
await refresh();
return true;

View File

@@ -0,0 +1,14 @@
// Safari/Firefox may require a native Paste confirmation. Do not trigger it on selection.
export async function mayReadClipboardAutomatically(
nav = navigator,
touch = nav.maxTouchPoints > 0 ||
(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches),
) {
try {
if (!nav.permissions?.query) return false;
const { state } = await nav.permissions.query({ name: 'clipboard-read' });
return state === 'granted' || (state === 'prompt' && !touch);
} catch {
return false;
}
}

View File

@@ -5,6 +5,8 @@ import { ref } from 'vue';
export function createDeliveryReceiver({ download, markRead, onError }) {
const pending = new Map();
const blockedLinks = ref([]);
const receivedTexts = ref([]);
const receivedLinks = ref([]);
function reserve(request) {
if (pending.has(request.id)) return;
const tabs = [];
@@ -45,13 +47,16 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
for (const item of items.filter((i) => i.batchId === request.batchId)) {
if (delivery.handled.has(item.accessId)) continue;
delivery.handled.add(item.accessId);
if (item.file) {
if (item.kind === 'text' && typeof item.text === 'string') {
receivedTexts.value.push(item);
} else if (item.file) {
void download(item).catch(onError);
} else if (item.url) {
const tab = delivery.tabs.shift();
if (tab && !tab.closed) {
try {
tab.location.replace(item.url);
receivedLinks.value = [item];
void markRead(item).catch(onError);
} catch {
tab.close();
@@ -70,12 +75,16 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
function dispose() {
for (const id of pending.keys()) abort(id);
blockedLinks.value = [];
receivedTexts.value = [];
receivedLinks.value = [];
}
return {
reserve,
abort,
reconcile,
blockedLinks,
receivedTexts,
receivedLinks,
opened,
dispose,
hasPending: () => pending.size > 0,

View File

@@ -3538,3 +3538,175 @@ a.button {
align-items: stretch;
}
}
.initial-device-loading {
display: grid;
gap: 16px;
}
.initial-device-loading p {
margin: 0;
}
.name-field-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.name-count {
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.peer-card,
.qr-connect-card {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.single-composer input.current-url {
min-height: 44px;
height: 44px;
}
.current-text {
width: 100%;
min-height: 150px;
height: 190px;
resize: none;
padding: 12px;
border-radius: 12px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.clipboard-count {
display: block;
margin-top: 8px;
text-align: right;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.received-text-dialog {
width: min(560px, 100%);
max-height: calc(100dvh - 48px);
overflow: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 22px;
padding: 24px;
}
.received-text-dialog header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.received-text-dialog h2 {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
font-size: 17px;
font-weight: 600;
overflow-wrap: anywhere;
}
.received-text-dialog h2 svg {
flex-shrink: 0;
}
.received-text-preview,
.account-text-preview {
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 4;
font-family: inherit;
line-height: 1.7;
max-height: 320px;
overflow: auto;
}
.received-text-preview {
padding: 16px;
border-radius: 14px;
background: var(--soft);
margin: 20px 0;
font-size: 14px;
}
.received-text-dialog > .button {
width: 100%;
min-height: 44px;
border-radius: 24px;
}
.received-link-notice {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
width: max-content;
max-width: calc(100vw - 32px);
border: 1px solid var(--line);
border-radius: 16px;
background: var(--surface);
box-shadow: 0 8px 32px #0004;
font-size: 12px;
}
.received-link-notice .text-button {
font-size: 12px;
white-space: nowrap;
}
.received-link-notice > svg {
flex-shrink: 0;
}
@media (max-width: 639px) {
.received-text-dialog {
padding: 20px;
}
.received-text-dialog h2 {
font-size: 16px;
}
.received-text-preview {
max-height: 45dvh;
}
}
.current-vault strong,
.current-vault small {
white-space: pre-wrap;
max-height: 180px;
overflow: auto;
}
.vault-choice strong {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
overflow-wrap: anywhere;
}
.content-pane.clipboard-pane {
display: flex;
flex-direction: column;
gap: 12px;
}
.clipboard-pane .content-heading {
margin: 0;
flex-shrink: 0;
}
.clipboard-pane .current-text {
flex: 1;
min-height: 48px;
height: 0;
}
.clipboard-pane .current-url,
.clipboard-pane .clipboard-count,
.clipboard-pane .content-feedback,
.clipboard-pane .composer-transfer-state {
flex-shrink: 0;
margin: 0;
}