Add plain text clipboard transfers and refine input feedback
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { inlinePayload } from './clipboardPayload.js';
|
||||
import type { CompleteInput } from '@hop/shared';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
|
||||
import { parseUrls, type AccountItem, type EmailRequest } from '@hop/shared';
|
||||
import { type AccountItem, type EmailRequest } from '@hop/shared';
|
||||
import type { Service, RequestRow, Guest } from './service.js';
|
||||
import type { User } from './auth.js';
|
||||
import * as s from './db/schema.js';
|
||||
@@ -44,7 +46,7 @@ export class Accounts {
|
||||
and(
|
||||
eq(s.requests.id, id),
|
||||
eq(s.requests.mode, 'email'),
|
||||
inArray(s.requests.kind, ['url', 'file']),
|
||||
inArray(s.requests.kind, ['url', 'text', 'file']),
|
||||
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
|
||||
),
|
||||
)
|
||||
@@ -124,7 +126,7 @@ export class Accounts {
|
||||
userId: string,
|
||||
input: {
|
||||
email: string;
|
||||
kind: 'url' | 'file';
|
||||
kind: 'url' | 'text' | 'file';
|
||||
byteSize: number;
|
||||
count: number;
|
||||
files?: { name: string; size: number; type: string }[];
|
||||
@@ -154,7 +156,8 @@ export class Accounts {
|
||||
if (policy === 'block') fail(400, 'TARGET_UNAVAILABLE', '이 주소로 전송할 수 없습니다.');
|
||||
if (
|
||||
input.count > cfg.maxItems ||
|
||||
(input.kind !== 'file' && input.byteSize > cfg.maxUrlBytes) ||
|
||||
(input.kind === 'url' && input.byteSize > cfg.maxUrlBytes) ||
|
||||
(input.kind === 'text' && input.byteSize > cfg.maxTextBytes) ||
|
||||
input.files?.some((f) => f.size > cfg.maxFileBytes)
|
||||
)
|
||||
fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.');
|
||||
@@ -256,7 +259,7 @@ export class Accounts {
|
||||
.where(eq(s.batches.id, batch.id))
|
||||
.run();
|
||||
}
|
||||
complete(userId: string, id: string, input: { urls: string[] }) {
|
||||
complete(userId: string, id: string, input: CompleteInput) {
|
||||
const row = this.request(userId, id);
|
||||
if (
|
||||
row.senderUserId !== userId ||
|
||||
@@ -273,13 +276,13 @@ export class Accounts {
|
||||
.where(eq(s.batches.requestId, id))
|
||||
.get()!;
|
||||
if (batch.status !== 'waiting') fail(409, 'TRANSFER_STATE', '이미 처리된 전송입니다.');
|
||||
if (row.kind !== 'url')
|
||||
fail(400, 'TYPE_UNSUPPORTED', '파일과 HTTP/HTTPS 링크만 보낼 수 있습니다.');
|
||||
const parsed = parseUrls(input.urls.join('\n'));
|
||||
if (parsed.invalid.length || parsed.urls.length !== input.urls.length)
|
||||
fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.');
|
||||
const values = parsed.urls;
|
||||
if (Buffer.byteLength(values.join('\n')) !== row.byteSize || values.length !== row.count)
|
||||
const { values, bytes } = inlinePayload(
|
||||
row.kind,
|
||||
input,
|
||||
this.service.cfg.maxTextBytes,
|
||||
this.service.cfg.maxUrlBytes,
|
||||
);
|
||||
if (bytes !== row.byteSize || values.length !== row.count)
|
||||
fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.');
|
||||
const now = Date.now();
|
||||
for (const value of values) {
|
||||
@@ -291,7 +294,7 @@ export class Accounts {
|
||||
batchId: batch.id,
|
||||
kind: row.kind,
|
||||
|
||||
url: value,
|
||||
...(row.kind === 'text' ? { text: value } : { url: value }),
|
||||
createdAt: now,
|
||||
expiresAt: now + this.service.cfg.urlRetentionDays * 86400000,
|
||||
})
|
||||
@@ -348,7 +351,7 @@ export class Accounts {
|
||||
and(
|
||||
eq(s.requests.mode, 'email'),
|
||||
gt(s.requests.createdAt, Date.now() - 7 * 86400000),
|
||||
inArray(s.requests.kind, ['url', 'file']),
|
||||
inArray(s.requests.kind, ['url', 'text', 'file']),
|
||||
or(eq(s.requests.senderUserId, userId), eq(s.requests.recipientUserId, userId)),
|
||||
),
|
||||
)
|
||||
@@ -391,7 +394,7 @@ export class Accounts {
|
||||
and(
|
||||
eq(s.access.id, id),
|
||||
eq(s.access.userId, userId),
|
||||
inArray(s.items.kind, ['url', 'file']),
|
||||
inArray(s.items.kind, ['url', 'text', 'file']),
|
||||
isNull(s.access.deletedAt),
|
||||
gt(s.items.expiresAt, Date.now()),
|
||||
),
|
||||
@@ -451,11 +454,11 @@ export class Accounts {
|
||||
s.access.role,
|
||||
view === 'inbox' ? 'recipient' : view === 'vault' ? 'self' : 'sender',
|
||||
),
|
||||
inArray(s.items.kind, ['url', 'file']),
|
||||
inArray(s.items.kind, ['url', 'text', 'file']),
|
||||
query.kind ? eq(s.items.kind, query.kind as any) : undefined,
|
||||
query.read !== undefined ? eq(s.access.read, query.read) : undefined,
|
||||
query.search
|
||||
? sql`(instr(lower(coalesce(${s.access.note},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.url},'')),lower(${query.search})) > 0 OR EXISTS (SELECT 1 FROM files f WHERE f.id = ${s.items.fileId} AND instr(lower(f.name),lower(${query.search})) > 0))`
|
||||
? sql`(instr(lower(coalesce(${s.access.note},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.url},'')),lower(${query.search})) > 0 OR instr(lower(coalesce(${s.items.text},'')),lower(${query.search})) > 0 OR EXISTS (SELECT 1 FROM files f WHERE f.id = ${s.items.fileId} AND instr(lower(f.name),lower(${query.search})) > 0))`
|
||||
: undefined,
|
||||
after
|
||||
? or(
|
||||
@@ -486,6 +489,7 @@ export class Accounts {
|
||||
batchId: r.item.batchId,
|
||||
kind: r.item.kind as 'url' | 'file',
|
||||
url: r.item.url,
|
||||
...(r.item.kind === 'text' ? { text: r.item.text } : {}),
|
||||
role: r.access.role,
|
||||
read: r.access.read,
|
||||
favorite: r.access.favorite,
|
||||
|
||||
@@ -332,6 +332,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
devices: service.peers(g),
|
||||
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
|
||||
spaceInviteCodeLength: cfg.spaceInviteCodeLength,
|
||||
clipboardTextLimitBytes: cfg.maxTextBytes,
|
||||
locationDiscovery: {
|
||||
enabled: cfg.locationDiscoveryEnabled,
|
||||
radiusMeters: cfg.locationRadiusMeters,
|
||||
@@ -819,6 +820,13 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
note: noteInput,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('text'),
|
||||
text: z.string().min(1).max(1048576),
|
||||
note: noteInput,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('file'),
|
||||
@@ -832,19 +840,26 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
const created = service.accounts.create(user.id, {
|
||||
email: user.email!,
|
||||
kind: input.kind,
|
||||
count: input.kind === 'url' ? input.urls.length : input.files.length,
|
||||
count:
|
||||
input.kind === 'text' ? 1 : input.kind === 'url' ? input.urls.length : input.files.length,
|
||||
byteSize:
|
||||
input.kind === 'url'
|
||||
? Buffer.byteLength(input.urls.join('\n'))
|
||||
: input.files.reduce((sum, file) => sum + file.size, 0),
|
||||
input.kind === 'text'
|
||||
? Buffer.byteLength(input.text, 'utf8')
|
||||
: input.kind === 'url'
|
||||
? Buffer.byteLength(input.urls.join('\n'))
|
||||
: input.files.reduce((sum, file) => sum + file.size, 0),
|
||||
...(input.kind === 'file' ? { files: input.files } : {}),
|
||||
});
|
||||
db.update(tables.requests)
|
||||
.set({ note: input.note || null })
|
||||
.where(eq(tables.requests.id, created.requestId))
|
||||
.run();
|
||||
if (input.kind === 'url')
|
||||
service.accounts.complete(user.id, created.requestId, { urls: input.urls });
|
||||
if (input.kind !== 'file')
|
||||
service.accounts.complete(
|
||||
user.id,
|
||||
created.requestId,
|
||||
input.kind === 'text' ? { text: input.text } : { urls: input.urls },
|
||||
);
|
||||
return created;
|
||||
});
|
||||
service.notifyTransfer(result.requestId);
|
||||
|
||||
24
apps/server/src/clipboardPayload.js
Normal file
24
apps/server/src/clipboardPayload.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { parseUrls } from '@hop/shared';
|
||||
import { fail } from './security.js';
|
||||
|
||||
// Called only after the request's sender and acceptance state have been checked.
|
||||
export function inlinePayload(kind, input, textLimit, urlLimit) {
|
||||
if (kind === 'text') {
|
||||
if (typeof input.text !== 'string' || !input.text.trim() || input.urls !== undefined)
|
||||
fail(400, 'INVALID_TEXT', '보낼 텍스트를 입력해 주세요.');
|
||||
const text = input.text;
|
||||
if (Buffer.from(text, 'utf8').toString('utf8') !== text)
|
||||
fail(400, 'INVALID_TEXT', '올바른 UTF-8 텍스트가 필요합니다.');
|
||||
const bytes = Buffer.byteLength(text, 'utf8');
|
||||
if (bytes > textLimit) fail(413, 'TEXT_LIMIT', '텍스트 전송 크기 제한을 초과했습니다.');
|
||||
return { values: [text], bytes };
|
||||
}
|
||||
if (kind !== 'url' || !Array.isArray(input.urls) || input.text !== undefined)
|
||||
fail(400, 'TYPE_MISMATCH', '요청한 자료 종류와 본문이 다릅니다.');
|
||||
const parsed = parseUrls(input.urls.join('\n'));
|
||||
if (parsed.invalid.length || parsed.urls.length !== input.urls.length)
|
||||
fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.');
|
||||
const bytes = Buffer.byteLength(parsed.urls.join('\n'), 'utf8');
|
||||
if (bytes > urlLimit) fail(413, 'TRANSFER_LIMIT', '링크 전송 크기 제한을 초과했습니다.');
|
||||
return { values: parsed.urls, bytes };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type Config = {
|
||||
spaceMax: number;
|
||||
offlineGraceMs: number;
|
||||
maxUrlBytes: number;
|
||||
maxTextBytes: number;
|
||||
maxItems: number;
|
||||
urlRetentionDays: number;
|
||||
sessionRate: number;
|
||||
@@ -68,6 +69,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters');
|
||||
if (num('LOCATION_DISCOVERY_RADIUS_METERS', 100) > 1000)
|
||||
throw Error('LOCATION_DISCOVERY_RADIUS_METERS must not exceed 1000');
|
||||
if (num('MAX_TEXT_PAYLOAD_SIZE_KB', 256) > 1024)
|
||||
throw Error('MAX_TEXT_PAYLOAD_SIZE_KB must not exceed 1024');
|
||||
const spaceInviteCodeLength = num('SPACE_INVITE_CODE_LENGTH', 4);
|
||||
if (![4, 6].includes(spaceInviteCodeLength))
|
||||
throw Error('SPACE_INVITE_CODE_LENGTH must be 4 or 6');
|
||||
@@ -100,6 +103,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
spaceMax: num('SPACE_MAX_DEVICES', 10),
|
||||
offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0),
|
||||
maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024,
|
||||
maxTextBytes: num('MAX_TEXT_PAYLOAD_SIZE_KB', 256) * 1024,
|
||||
maxItems: num('MAX_ITEMS_PER_TRANSFER', 20),
|
||||
urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)),
|
||||
sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { inlinePayload } from './clipboardPayload.js';
|
||||
import type { CompleteInput } from '@hop/shared';
|
||||
import type { Server } from 'socket.io';
|
||||
import type { Auth } from './auth.js';
|
||||
import type { Accounts } from './accounts.js';
|
||||
@@ -13,14 +15,7 @@ import * as s from './db/schema.js';
|
||||
import type { Config } from './config.js';
|
||||
import type { Presence } from './presence.js';
|
||||
import { fail, hash, hmac, randomToken, Limiter } from './security.js';
|
||||
import {
|
||||
parseUrls,
|
||||
type Device,
|
||||
type Item,
|
||||
type Peer,
|
||||
type Space,
|
||||
type TransferRequest,
|
||||
} from '@hop/shared';
|
||||
import { type Device, type Item, type Peer, type Space, type TransferRequest } from '@hop/shared';
|
||||
export type Guest = typeof s.guests.$inferSelect;
|
||||
export type RequestRow = typeof s.requests.$inferSelect;
|
||||
const day = 86400000;
|
||||
@@ -133,7 +128,7 @@ export class Service {
|
||||
.from(s.requests)
|
||||
.where(
|
||||
and(
|
||||
inArray(s.requests.kind, ['url', 'file']),
|
||||
inArray(s.requests.kind, ['url', 'text', 'file']),
|
||||
or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)),
|
||||
),
|
||||
)
|
||||
@@ -580,7 +575,7 @@ export class Service {
|
||||
g: Guest,
|
||||
input: {
|
||||
targetDeviceId: string;
|
||||
kind: 'url' | 'file';
|
||||
kind: 'url' | 'text' | 'file';
|
||||
byteSize: number;
|
||||
count: number;
|
||||
files?: { name: string; size: number; type: string }[];
|
||||
@@ -598,7 +593,8 @@ export class Service {
|
||||
)
|
||||
fail(403, 'TARGET_UNAVAILABLE', '이 기기로 보낼 수 없습니다.');
|
||||
if (
|
||||
(input.kind !== 'file' && input.byteSize > this.cfg.maxUrlBytes) ||
|
||||
(input.kind === 'url' && input.byteSize > this.cfg.maxUrlBytes) ||
|
||||
(input.kind === 'text' && input.byteSize > this.cfg.maxTextBytes) ||
|
||||
input.count > this.cfg.maxItems ||
|
||||
input.files?.some((f) => f.size > this.cfg.maxFileBytes)
|
||||
)
|
||||
@@ -756,7 +752,7 @@ export class Service {
|
||||
this.notifyRequests();
|
||||
this.refresh();
|
||||
}
|
||||
complete(g: Guest, id: string, input: { urls: string[] }) {
|
||||
complete(g: Guest, id: string, input: CompleteInput) {
|
||||
const row = this.request(g, id);
|
||||
if (row.kind === 'file')
|
||||
fail(400, 'FILE_UPLOAD_REQUIRED', '파일은 업로드 API를 사용해 주세요.');
|
||||
@@ -770,14 +766,13 @@ export class Service {
|
||||
fail(409, 'REQUEST_STATE', '수락된 유효한 요청이 필요합니다.');
|
||||
const batch = this.db.select().from(s.batches).where(eq(s.batches.requestId, id)).get()!;
|
||||
if (batch.status !== 'waiting') fail(409, 'TRANSFER_STATE', '이미 처리된 전송입니다.');
|
||||
if (row.kind !== 'url')
|
||||
fail(400, 'TYPE_UNSUPPORTED', '파일과 HTTP/HTTPS 링크만 보낼 수 있습니다.');
|
||||
const parsed = parseUrls(input.urls.join('\n'));
|
||||
if (parsed.invalid.length || parsed.urls.length !== input.urls.length)
|
||||
fail(400, 'INVALID_URL', 'HTTP 또는 HTTPS 링크만 보낼 수 있습니다.');
|
||||
const values = parsed.urls;
|
||||
const bytes = Buffer.byteLength(values.join('\n'), 'utf8');
|
||||
if (bytes !== row.byteSize || values.length !== row.count || bytes > this.cfg.maxUrlBytes)
|
||||
const { values, bytes } = inlinePayload(
|
||||
row.kind,
|
||||
input,
|
||||
this.cfg.maxTextBytes,
|
||||
this.cfg.maxUrlBytes,
|
||||
);
|
||||
if (bytes !== row.byteSize || values.length !== row.count)
|
||||
fail(409, 'METADATA_MISMATCH', '내용 크기나 개수가 바뀌었습니다. 새 요청을 보내 주세요.');
|
||||
const now = Date.now();
|
||||
for (const value of values) {
|
||||
@@ -789,7 +784,7 @@ export class Service {
|
||||
batchId: batch.id,
|
||||
kind: row.kind,
|
||||
|
||||
url: value,
|
||||
...(row.kind === 'text' ? { text: value } : { url: value }),
|
||||
createdAt: now,
|
||||
expiresAt: now + this.cfg.urlRetentionDays * day,
|
||||
})
|
||||
@@ -811,7 +806,7 @@ export class Service {
|
||||
.innerJoin(s.batches, eq(s.requests.id, s.batches.requestId))
|
||||
.where(
|
||||
and(
|
||||
inArray(s.requests.kind, ['url', 'file']),
|
||||
inArray(s.requests.kind, ['url', 'text', 'file']),
|
||||
gt(s.requests.createdAt, Date.now() - 7 * day),
|
||||
or(eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, g.id)),
|
||||
),
|
||||
@@ -867,7 +862,7 @@ export class Service {
|
||||
.where(
|
||||
and(
|
||||
eq(s.access.guestId, g.id),
|
||||
inArray(s.items.kind, ['url', 'file']),
|
||||
inArray(s.items.kind, ['url', 'text', 'file']),
|
||||
isNull(s.access.deletedAt),
|
||||
gt(s.items.expiresAt, Date.now()),
|
||||
after
|
||||
@@ -896,6 +891,7 @@ export class Service {
|
||||
batchId: r.item.batchId,
|
||||
kind: r.item.kind as 'url' | 'file',
|
||||
url: r.item.url,
|
||||
...(r.item.kind === 'text' ? { text: r.item.text } : {}),
|
||||
file: r.item.fileId
|
||||
? (() => {
|
||||
const f = this.db.select().from(s.files).where(eq(s.files.id, r.item.fileId!)).get()!;
|
||||
|
||||
@@ -88,17 +88,25 @@ export class Vault {
|
||||
forward(guest, current, accessId, targetDeviceId, ip) {
|
||||
const source = { userId: current.user.id, sessionId: current.session.id, accessId };
|
||||
const row = this.authorize(source);
|
||||
const input = row.item.url
|
||||
? { targetDeviceId, kind: 'url', byteSize: Buffer.byteLength(row.item.url), count: 1 }
|
||||
: {
|
||||
targetDeviceId,
|
||||
kind: 'file',
|
||||
byteSize: row.file.size,
|
||||
count: 1,
|
||||
files: [
|
||||
{ id: row.file.id, name: row.file.name, size: row.file.size, type: row.file.type },
|
||||
],
|
||||
};
|
||||
const input =
|
||||
row.item.kind === 'text'
|
||||
? {
|
||||
targetDeviceId,
|
||||
kind: 'text',
|
||||
byteSize: Buffer.byteLength(row.item.text, 'utf8'),
|
||||
count: 1,
|
||||
}
|
||||
: row.item.url
|
||||
? { targetDeviceId, kind: 'url', byteSize: Buffer.byteLength(row.item.url), count: 1 }
|
||||
: {
|
||||
targetDeviceId,
|
||||
kind: 'file',
|
||||
byteSize: row.file.size,
|
||||
count: 1,
|
||||
files: [
|
||||
{ id: row.file.id, name: row.file.name, size: row.file.size, type: row.file.type },
|
||||
],
|
||||
};
|
||||
const result = this.service.createRequest(guest, input, ip, true);
|
||||
this.service.db
|
||||
.insert(s.vaultTransfers)
|
||||
@@ -128,6 +136,7 @@ export class Vault {
|
||||
batchId: batch.id,
|
||||
kind: row.item.kind,
|
||||
url: row.item.url,
|
||||
text: row.item.text,
|
||||
fileId: row.item.fileId,
|
||||
createdAt: now,
|
||||
expiresAt: now + 7 * 86400000,
|
||||
|
||||
25
apps/server/test/clipboard-access.test.js
Normal file
25
apps/server/test/clipboard-access.test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mayReadClipboardAutomatically } from '../../web/src/clipboardAccess.js';
|
||||
|
||||
test('클립보드 자동 읽기는 모바일 확인과 지원하지 않는 권한 API를 우회하지 않는다', async () => {
|
||||
assert.equal(await mayReadClipboardAutomatically({}, true), false);
|
||||
const nav = (state) => ({ permissions: { query: async () => ({ state }) } });
|
||||
assert.equal(await mayReadClipboardAutomatically(nav('granted'), true), true);
|
||||
assert.equal(await mayReadClipboardAutomatically(nav('prompt'), true), false);
|
||||
assert.equal(await mayReadClipboardAutomatically(nav('denied'), false), false);
|
||||
assert.equal(await mayReadClipboardAutomatically(nav('prompt'), false), true);
|
||||
assert.equal(
|
||||
await mayReadClipboardAutomatically(
|
||||
{
|
||||
permissions: {
|
||||
query: async () => {
|
||||
throw new TypeError('unsupported');
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
207
apps/server/test/clipboard.test.js
Normal file
207
apps/server/test/clipboard.test.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fixture } from './helpers.js';
|
||||
import { classifyClipboard, clipboardCharacterCount } from '@hop/shared';
|
||||
import * as s from '../src/db/schema.js';
|
||||
|
||||
const raw =
|
||||
' 첫 줄 😀\r\n日本語の文章\n\t URL은 https://example.com 안에 있어요. \n<script>window.__xss = true</script>';
|
||||
function limits(f) {
|
||||
f.cfg.sessionRate = 100;
|
||||
f.cfg.ipRate = 200;
|
||||
f.cfg.pairRate = 100;
|
||||
}
|
||||
async function textRequest(f, a, b, value, bytes = Buffer.byteLength(value)) {
|
||||
return f.call(
|
||||
a,
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{ targetDeviceId: b.deviceId, kind: 'text', count: 1, byteSize: bytes },
|
||||
randomUUID(),
|
||||
);
|
||||
}
|
||||
async function signed(f, client, email) {
|
||||
const id = randomUUID();
|
||||
f.service.db
|
||||
.insert(s.users)
|
||||
.values({
|
||||
id,
|
||||
email,
|
||||
displayName: email,
|
||||
passwordHash: await f.service.auth.password('1234'),
|
||||
status: 'active',
|
||||
verifiedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
const login = await f.call(client, '/auth/login', 'POST', { email, password: '1234' });
|
||||
assert.equal(login.statusCode, 200);
|
||||
client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
|
||||
return id;
|
||||
}
|
||||
|
||||
test('클립보드는 전체 단일 URL만 링크로 판별하고 나머지 원문과 이모지를 보존한다', () => {
|
||||
for (const [input, url] of [
|
||||
['https://example.com/article', 'https://example.com/article'],
|
||||
['http://127.0.0.1/path', 'http://127.0.0.1/path'],
|
||||
['www.example.com', 'https://www.example.com/'],
|
||||
['example.com/path', 'https://example.com/path'],
|
||||
])
|
||||
assert.deepEqual(classifyClipboard(input), { kind: 'url', url });
|
||||
for (const value of [
|
||||
raw,
|
||||
'',
|
||||
' ',
|
||||
'여기 https://example.com 보세요',
|
||||
'https://one.test/\nhttps://two.test/',
|
||||
'javascript:alert(1)',
|
||||
'https://user:pass@example.com',
|
||||
'https://example.com/\n',
|
||||
])
|
||||
assert.deepEqual(classifyClipboard(value), { kind: 'text', text: value });
|
||||
assert.equal(clipboardCharacterCount('한글日本語😀'), 6);
|
||||
});
|
||||
|
||||
test('텍스트는 수락 전 비공개이며 수락 후 원문·UTF-8·접근권·읽음·멱등성을 유지한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
const a = await f.create(),
|
||||
b = await f.create(),
|
||||
c = await f.create();
|
||||
await f.space(a, b);
|
||||
const response = await textRequest(f, a, b, raw);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
const id = response.json().requestId;
|
||||
assert.equal(
|
||||
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID()))
|
||||
.statusCode,
|
||||
409,
|
||||
);
|
||||
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
|
||||
assert.equal((await f.call(b, '/device/snapshot')).json().requests[0].text, undefined);
|
||||
assert.notEqual(
|
||||
(await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
|
||||
200,
|
||||
);
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
assert.notEqual(
|
||||
(await f.call(c, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID()))
|
||||
.statusCode,
|
||||
200,
|
||||
);
|
||||
const key = randomUUID();
|
||||
assert.equal(
|
||||
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, key)).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, key)).statusCode,
|
||||
200,
|
||||
);
|
||||
const item = (await f.call(b, '/device/items')).json().items[0];
|
||||
assert.equal(item.kind, 'text');
|
||||
assert.equal(item.text, raw);
|
||||
assert.equal(item.url, null);
|
||||
assert.equal((await f.call(c, '/device/items')).json().items.length, 0);
|
||||
assert.equal(f.service.db.select().from(s.items).all().length, 1);
|
||||
assert.equal(f.service.db.select().from(s.files).all().length, 0);
|
||||
await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true });
|
||||
assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, true);
|
||||
assert(item.expiresAt - item.createdAt <= 7 * 86400000);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('텍스트의 UTF-8 256KiB 한도와 본문 타입·빈 내용·승인 크기 변경을 검사한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
assert.equal(f.cfg.maxTextBytes, 256 * 1024);
|
||||
assert.equal((await textRequest(f, a, b, '가'.repeat(87382))).statusCode, 413);
|
||||
const boundary = 'x'.repeat(256 * 1024);
|
||||
const id = (await textRequest(f, a, b, boundary)).json().requestId;
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
assert.equal(
|
||||
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: boundary }, randomUUID()))
|
||||
.statusCode,
|
||||
200,
|
||||
);
|
||||
const small = (await textRequest(f, a, b, 'x')).json().requestId;
|
||||
await f.call(b, `/transfer-requests/${small}/accept`, 'POST', {}, randomUUID());
|
||||
for (const [body, status] of [
|
||||
[{ text: boundary + 'x' }, 413],
|
||||
[{ text: ' ' }, 400],
|
||||
[{ text: '変更 😀' }, 409],
|
||||
[{ urls: ['https://example.com/'] }, 400],
|
||||
[{ text: 'x', urls: [] }, 400],
|
||||
])
|
||||
assert.equal(
|
||||
(await f.call(a, `/transfers/${small}/complete`, 'POST', body, randomUUID())).statusCode,
|
||||
status,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(a, `/transfers/${small}/complete`, 'POST', { text: 'x' }, randomUUID()))
|
||||
.statusCode,
|
||||
200,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('로그인 중 받은 텍스트는 7일 기록하고 임시 저장·검색·보관함 재전송도 원문을 유지한다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
limits(f);
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
await signed(f, b, 'text@hop.test');
|
||||
const id = (await textRequest(f, a, b, raw)).json().requestId;
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID());
|
||||
const receipt = (await f.call(b, '/inbox')).json().items[0];
|
||||
assert.equal(receipt.text, raw);
|
||||
const saved = await f.call(
|
||||
b,
|
||||
'/vault',
|
||||
'POST',
|
||||
{ kind: 'text', text: raw, note: '保存 메모' },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(saved.statusCode, 200, saved.body);
|
||||
const vault = (await f.call(b, '/vault?search=' + encodeURIComponent('日本語'))).json().items;
|
||||
assert.equal(vault.length, 1);
|
||||
assert.equal(vault[0].text, raw);
|
||||
const forwarded = await f.call(
|
||||
b,
|
||||
`/vault/${vault[0].accessId}/send`,
|
||||
'POST',
|
||||
{ targetDeviceId: a.deviceId },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(forwarded.statusCode, 200, forwarded.body);
|
||||
const sentId = forwarded.json().requestId;
|
||||
await f.call(a, `/transfer-requests/${sentId}/accept`, 'POST', {}, randomUUID());
|
||||
assert(
|
||||
(await f.call(a, '/device/items'))
|
||||
.json()
|
||||
.items.some((item) => item.role === 'recipient' && item.text === raw),
|
||||
);
|
||||
f.service.db
|
||||
.update(s.items)
|
||||
.set({ expiresAt: Date.now() - 1 })
|
||||
.where(eq(s.items.id, vault[0].id))
|
||||
.run();
|
||||
assert.equal((await f.call(b, '/vault')).json().items.length, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -509,7 +509,7 @@ test('임의 X-Forwarded-For는 신뢰하지 않고 명시된 프록시 체인
|
||||
);
|
||||
});
|
||||
|
||||
test('신규 일반 텍스트 요청과 본문 확정을 거절하고 URL·파일만 지원', async () => {
|
||||
test('텍스트 요청은 한 항목만 허용하고 URL 요청에 다른 본문 타입을 확정하지 않는다', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
@@ -521,7 +521,7 @@ test('신규 일반 텍스트 요청과 본문 확정을 거절하고 URL·파
|
||||
a,
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{ targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 1 },
|
||||
{ targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 2 },
|
||||
randomUUID(),
|
||||
)
|
||||
).statusCode,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
apps/web/src/clipboardAccess.js
Normal file
14
apps/web/src/clipboardAccess.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user