更新优化

This commit is contained in:
annali168290-ux
2026-10-10 21:20:27 +07:00
parent a5efae932b
commit cf66f936e5
31 changed files with 2752 additions and 850 deletions
BIN
View File
Binary file not shown.
+20 -4
View File
@@ -6,6 +6,18 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
function mediaProxyEnv(string $name, string $default = ''): string
{
if (function_exists('webImEnv')) {
return (string)webImEnv($name, $default);
}
$value = getenv($name);
if ($value === false || trim((string)$value) === '') {
return $default;
}
return trim((string)$value);
}
function failMediaProxy(int $status, string $message): never
{
http_response_code($status);
@@ -18,12 +30,13 @@ function failMediaProxy(int $status, string $message): never
function mediaProxyAllowedHosts(): array
{
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$hosts = array_map(
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
preg_split('/[,\s]+/', $configured) ?: []
);
$hosts[] = 'zhiyanim.dfpnm.cn';
return array_values(array_unique(array_filter($hosts)));
}
@@ -53,9 +66,12 @@ function validatedMediaProxyUrl(string $url): array
function mediaProxyCacheRoot(): string
{
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
if ($root === '' || !is_dir($root) || !is_writable($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
}
}
return $root;
@@ -63,7 +79,7 @@ function mediaProxyCacheRoot(): string
function mediaProxyPreferredIps(): array
{
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$ips = [];
foreach ($configured as $ip) {
$ip = trim($ip);
+50 -3
View File
@@ -78,7 +78,7 @@ function fetchActionMessageRow(string $messageUid): array
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
@@ -93,7 +93,40 @@ function ensureMessageEditsTable(): void
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
}
function ensurePinnedMessagesTable(): void
{
imAppDb()->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null,'
. '`conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null,'
. '`message_uid` varchar(32) not null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$userId, $conversationType, $targetId, $messageUid, nowTimestampMs()]);
}
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
);
$statement->execute([$userId, $conversationType, $targetId]);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
@@ -222,7 +255,21 @@ if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
try {
if ($action === 'pin' || $action === 'unpin') {
if ($userId === '') respond(400, '缺少操作者ID');
$targetId = trim((string)($payload['target'] ?? ''));
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
if ($targetId === '') respond(400, '缺少会话ID');
$userId = resolveActionUserId($userId);
if ($action === 'pin') {
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
}
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
+110 -16
View File
@@ -213,6 +213,9 @@ function attachmentFallbackText(?array $attachment): string
if ((string)$attachment['type'] === 'image') {
return '[图片]';
}
if ((string)$attachment['type'] === 'video') {
return '[视频]';
}
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
}
@@ -641,6 +644,9 @@ function nativeMediaUrlScore(string $url): int
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
$score += 30;
}
if (preg_match('/\.(?:mp4|mov|m4v|webm|avi|mkv|3gp)$/i', $path)) {
$score += 30;
}
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
$score += 5;
}
@@ -691,11 +697,21 @@ function nativeAttachmentName(string $url, string $type): string
return $name;
}
return $type === 'image' ? '图片' : '文件';
return $type === 'image' ? '图片' : ($type === 'video' ? '视频' : '文件');
}
function nativeAttachmentMime(string $url, string $type): string
{
if ($type === 'video') {
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (preg_match('/\.webm$/', $path)) {
return 'video/webm';
}
if (preg_match('/\.(?:mov|m4v)$/', $path)) {
return 'video/quicktime';
}
return 'video/mp4';
}
if ($type !== 'image') {
return '';
}
@@ -730,7 +746,7 @@ function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
}
$contentType = (int)($row['_content_type'] ?? 0);
if ($contentType !== 3 && $contentType !== 5) {
if (!in_array($contentType, [3, 5, 6], true)) {
return null;
}
@@ -739,7 +755,7 @@ function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
return null;
}
$type = $contentType === 3 ? 'image' : 'file';
$type = $contentType === 3 ? 'image' : ($contentType === 6 ? 'video' : 'file');
return [
'type' => $type,
@@ -767,7 +783,7 @@ function nativeDisplayText(string $searchableText, int $contentType, bool $hasAt
{
$text = trim($searchableText);
if (isNativePlaceholderText($text, $contentType)) {
return '';
$text = '';
}
if ($text === '' && !$isRecalled) {
return nativeMessageFallbackText($contentType, $hasAttachment);
@@ -776,6 +792,29 @@ function nativeDisplayText(string $searchableText, int $contentType, bool $hasAt
return $text;
}
function nativeRedPacketFromText(string $text): ?array
{
$decoded = json_decode(trim($text), true);
if (!is_array($decoded)) {
return null;
}
$packet = $decoded['redPackets'] ?? $decoded['redPacket'] ?? null;
if (!is_array($packet)) {
return null;
}
return [
'id' => (string)($packet['id'] ?? ''),
'senderId' => (string)($packet['userId'] ?? $packet['senderId'] ?? ''),
'target' => (string)($packet['target'] ?? $decoded['target'] ?? ''),
'blessing' => trim((string)($packet['blessing'] ?? '')) ?: '恭喜发财,大吉大利',
'status' => (int)($packet['status'] ?? 0),
'expireTime' => (string)($packet['expireTime'] ?? ''),
'createDate' => (string)($packet['createDate'] ?? ''),
];
}
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
{
if ($hasAttachment) {
@@ -784,9 +823,18 @@ function nativeMessageFallbackText(int $contentType, bool $hasAttachment): strin
if ($contentType === 5) {
return '[文件]';
}
if ($contentType === 6) {
return '[视频加载失败]';
}
if ($contentType === 90) {
return '[红包]';
}
if ($contentType === 92) {
return '以上是打招呼的内容';
}
if ($contentType === 93) {
return '你们已经是好友了,可以开始聊天了。';
}
if ($contentType === 105) {
return '[领取了红包]';
}
@@ -892,13 +940,19 @@ function formatNativeGroupMessage(
$messageId = (string)$row['_mid'];
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$contentType = (int)($row['_content_type'] ?? 0);
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
if ($redPacket !== null) {
$contentType = 90;
}
$isRecalled = $contentType === 80;
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment
? (string)$attachment['type']
: (in_array($contentType, [1, 80, 90, 105], true) ? 'text' : 'unknown');
$text = nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
$text = $redPacket !== null
? '[红包]'
: nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
return [
'id' => $messageId,
@@ -929,6 +983,7 @@ function formatNativeGroupMessage(
'contentType' => $contentType,
'text' => attachmentDisplayText($text, $attachment),
'attachment' => $attachment,
'redPacket' => $redPacket,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
'recalled' => $isRecalled,
@@ -1333,6 +1388,10 @@ function formatMessage(
$messageId = (string)$row['_mid'];
$from = (string)$row['_from'];
$contentType = (int)($row['_content_type'] ?? 0);
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
if ($redPacket !== null) {
$contentType = 90;
}
$isRecalled = $contentType === 80;
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
@@ -1340,8 +1399,10 @@ function formatMessage(
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment
? (string)$attachment['type']
: (in_array($contentType, [1, 80, 90, 105], true) ? 'text' : 'unknown');
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
$messageText = $redPacket !== null
? '[红包]'
: nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
@@ -1358,9 +1419,12 @@ function formatMessage(
'target' => $peerId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
'contentType' => $attachment
? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5))
: $contentType,
'text' => $messageText,
'attachment' => $attachment,
'redPacket' => $redPacket,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
'deliveryStatus' => $deliveryStatus,
'recalled' => $isRecalled,
@@ -1510,7 +1574,16 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
'raw' => ['type' => 'single', 'target' => $peerId, 'line' => 0],
'raw' => [
'type' => 'single',
'target' => $peerId,
'line' => 0,
'userId' => (string)($users[$peerId]['_name'] ?? $peerId),
'userUid' => $peerId,
'displayName' => (string)($users[$peerId]['_display_name'] ?? ''),
'mobile' => (string)($users[$peerId]['_mobile'] ?? ''),
'address' => (string)($users[$peerId]['_address'] ?? ''),
],
];
}
@@ -1536,13 +1609,34 @@ function messageSyncResult(
string $userId,
array $messages,
bool $includeConversations,
int $limit
int $limit,
string $targetId = '',
string $conversationType = 'single'
): array {
$result = ['messages' => $messages];
if ($includeConversations) {
$result['conversations'] = loadRecentConversations($db, $userId, $limit);
}
if ($targetId !== '') {
try {
imAppDb()->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
$statement = imAppDb()->prepare(
'select `message_uid` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
);
$statement->execute([$userId, $conversationType, $targetId]);
$result['pinnedMessageUid'] = (string)($statement->fetchColumn() ?: '');
} catch (Throwable) {
$result['pinnedMessageUid'] = '';
}
}
return $result;
}
@@ -1571,7 +1665,7 @@ try {
$userId,
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
if (isNativeGroupTarget($db, $target)) {
@@ -1580,7 +1674,7 @@ try {
$userId,
loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
respond(0, 'success', messageSyncResult(
@@ -1588,7 +1682,7 @@ try {
$userId,
[],
$includeConversations,
$limit
$limit, $target, 'group'
));
}
if (isWebGroupTarget(imAppDb(), $target)) {
@@ -1597,7 +1691,7 @@ try {
$userId,
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
$targetId = resolveUserId($db, $target);
@@ -1606,7 +1700,7 @@ try {
$userId,
loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
$includeConversations,
$limit
$limit, $targetId, 'single'
));
}
+267 -64
View File
@@ -1268,10 +1268,48 @@ button {
text-align: left;
}
.conversation-item.active,
.conversation-item:hover {
background: #eaf3f1;
}
.conversation-item.active,
.conversation-item:hover {
background: #eaf3f1;
}
.conversation-item.pinned {
background: rgba(15, 118, 110, 0.055);
}
.conversation-pin {
display: inline-block;
margin-right: 5px;
color: #0f766e;
font-size: 12px;
transform: rotate(-25deg);
}
.conversation-context-menu {
position: fixed;
z-index: 1200;
min-width: 124px;
padding: 6px;
border: 1px solid #d7e0e8;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
}
.conversation-context-menu button {
width: 100%;
padding: 9px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: #1f2937;
text-align: left;
cursor: pointer;
}
.conversation-context-menu button:hover {
background: #eef7f6;
}
.favorite-item {
width: 100%;
@@ -1485,7 +1523,7 @@ button {
box-shadow: 0 0 0 2px #f7fafc;
}
.chat-panel {
.chat-panel {
position: relative;
min-width: 0;
min-height: 0;
@@ -1493,8 +1531,12 @@ button {
display: grid;
grid-template-rows: 72px minmax(0, 1fr) auto auto;
overflow: hidden;
background: #eef2f5;
}
background: #eef2f5;
}
.chat-panel.has-pinned-message {
grid-template-rows: 72px auto minmax(0, 1fr) auto auto;
}
.contact-panel {
min-width: 0;
@@ -2273,7 +2315,7 @@ button {
max-width: 100%;
}
.message-row.out .message-bubble {
.message-row.out .message-bubble {
background: var(--bubble-out);
border-color: #bfe4dc;
}
@@ -2326,39 +2368,186 @@ button {
margin-top: 8px;
}
.message-image img {
.message-image img {
display: block;
max-width: 100%;
max-height: 320px;
-o-object-fit: contain;
object-fit: contain;
}
}
.pinned-message-banner {
width: calc(100% - 28px);
height: 42px;
min-height: 42px;
margin: 8px 14px;
align-self: center;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #d5e2df;
border-radius: 9px;
background: #f7fbfa;
color: #52606d;
text-align: left;
cursor: pointer;
}
.pinned-message-banner strong {
color: #0f766e;
white-space: nowrap;
}
.pinned-message-preview {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
overflow: hidden;
}
.pinned-message-preview > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pinned-message-preview img {
width: 28px;
height: 28px;
flex: 0 0 28px;
border-radius: 4px;
-o-object-fit: cover;
object-fit: cover;
}
.pinned-message-close {
padding: 0 4px;
color: #748091;
font-size: 20px;
}
.message-row.system {
justify-content: center;
padding-top: 5px;
padding-bottom: 5px;
}
.system-message-pill {
max-width: min(78%, 620px);
padding: 5px 12px;
border-radius: 5px;
background: #c8c8c8;
color: #fff;
font-size: 12px;
line-height: 1.45;
text-align: center;
}
.message-video {
display: block;
width: min(360px, 68vw);
max-height: 360px;
border-radius: 7px;
background: #111;
}
.red-packet-card {
width: min(320px, 68vw);
margin: -10px -12px -8px;
overflow: hidden;
border-radius: 7px;
background: #ff9f32;
color: #fff;
}
.red-packet-card-main {
display: flex;
align-items: center;
gap: 13px;
padding: 20px 18px 17px;
}
.red-packet-card-main > span:last-child {
display: grid;
gap: 7px;
}
.red-packet-card-main strong {
font-size: 17px;
font-weight: 700;
}
.red-packet-card-main small {
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
}
.red-packet-icon,
.red-packet-mini-icon {
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 3px;
background: #d92f27;
color: #ffd968;
font-weight: 800;
}
.red-packet-icon {
width: 34px;
height: 44px;
font-size: 17px;
}
.red-packet-card-footer {
padding: 9px 15px;
background: #fff;
color: #8a8f94;
font-size: 12px;
}
.red-packet-receipt {
display: flex;
align-items: center;
gap: 7px;
color: #69747d;
}
.red-packet-mini-icon {
width: 22px;
height: 28px;
font-size: 11px;
}
.image-viewer-backdrop {
.image-viewer-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(12, 18, 26, 0.58);
}
.image-viewer-dialog {
width: min(880px, calc(100vw - 48px));
height: min(640px, calc(100vh - 48px));
min-width: 360px;
min-height: 280px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px);
padding: 20px;
background: rgba(7, 12, 20, 0.82);
backdrop-filter: blur(6px);
}
.image-viewer-dialog {
width: min(920px, calc(100vw - 64px));
height: min(660px, calc(100vh - 64px));
min-width: 320px;
min-height: 260px;
max-width: calc(100vw - 64px);
max-height: calc(100vh - 64px);
display: grid;
grid-template-rows: 52px minmax(0, 1fr);
overflow: hidden;
resize: both;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: #101820;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.36);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px;
background: #0b1119;
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.56);
}
.image-viewer-toolbar {
@@ -2372,17 +2561,6 @@ button {
background: rgba(24, 33, 47, 0.94);
}
.image-viewer-title {
min-width: 0;
flex: 1;
overflow: hidden;
color: #f8fafc;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-viewer-toolbar button {
width: 36px;
height: 36px;
@@ -2401,42 +2579,67 @@ button {
background: rgba(255, 255, 255, 0.14);
}
.image-viewer-stage {
.image-viewer-stage {
position: relative;
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
overflow: hidden;
background: #0c121a;
cursor: default;
touch-action: none;
}
overflow: hidden;
background: #0c121a;
cursor: default;
}
.image-viewer-nav {
position: absolute;
top: 50%;
z-index: 2;
width: 52px;
height: 52px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
background: rgba(18, 27, 39, 0.76);
color: #fff;
font-size: 42px;
font-weight: 300;
line-height: 1;
transform: translateY(-50%);
cursor: pointer;
backdrop-filter: blur(5px);
}
.image-viewer-nav:hover {
background: rgba(41, 57, 75, 0.94);
}
.image-viewer-prev {
left: 18px;
}
.image-viewer-next {
right: 18px;
}
.image-viewer-stage.zoomed {
cursor: grab;
}
.image-viewer-stage.dragging {
cursor: grabbing;
}
.image-viewer-img {
max-width: calc(100% - 32px);
max-height: calc(100% - 32px);
-o-object-fit: contain;
object-fit: contain;
.image-viewer-img {
position: absolute;
top: 20px;
left: 90px;
width: calc(100% - 180px);
height: calc(100% - 40px);
max-width: none;
max-height: none;
-o-object-fit: contain;
object-fit: contain;
transform-origin: center center;
transition: transform 0.08s ease-out;
-webkit-user-select: none;
-moz-user-select: none;
transition: transform 0.12s ease-out;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
pointer-events: none;
}
.image-viewer-stage.dragging .image-viewer-img {
transition: none;
}
.sender-profile-backdrop {
position: fixed;
inset: 0;
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="favicon.ico"><script src="config/runtime-config.production.js"></script><script defer="defer" src="js/chunk-vendors.d3ec3fbc.js"></script><script defer="defer" src="js/app.d5ef3122.js"></script><link href="css/app.5af3ef7e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="favicon.ico"><script src="config/runtime-config.production.js"></script><script defer="defer" src="js/chunk-vendors.d3ec3fbc.js"></script><script defer="defer" src="js/app.1158390e.js"></script><link href="css/app.3ff3e457.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+20 -4
View File
@@ -6,6 +6,18 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
function mediaProxyEnv(string $name, string $default = ''): string
{
if (function_exists('webImEnv')) {
return (string)webImEnv($name, $default);
}
$value = getenv($name);
if ($value === false || trim((string)$value) === '') {
return $default;
}
return trim((string)$value);
}
function failMediaProxy(int $status, string $message): never
{
http_response_code($status);
@@ -18,12 +30,13 @@ function failMediaProxy(int $status, string $message): never
function mediaProxyAllowedHosts(): array
{
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$hosts = array_map(
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
preg_split('/[,\s]+/', $configured) ?: []
);
$hosts[] = 'zhiyanim.dfpnm.cn';
return array_values(array_unique(array_filter($hosts)));
}
@@ -53,9 +66,12 @@ function validatedMediaProxyUrl(string $url): array
function mediaProxyCacheRoot(): string
{
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
if ($root === '' || !is_dir($root) || !is_writable($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
}
}
return $root;
@@ -63,7 +79,7 @@ function mediaProxyCacheRoot(): string
function mediaProxyPreferredIps(): array
{
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$ips = [];
foreach ($configured as $ip) {
$ip = trim($ip);
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
function failMediaStream(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$url = trim((string)($_GET['url'] ?? ''));
if ($url === '') {
failMediaStream(400, '缺少视频地址');
}
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failMediaStream(400, '视频地址不合法');
}
$root = realpath(WEB_MEDIA_STREAM_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failMediaStream(404, '视频文件不存在');
}
$size = (int)filesize($fullPath);
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
if (!str_starts_with($mime, 'video/')) {
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
}
$start = 0;
$end = max(0, $size - 1);
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
if ($matches[1] !== '') $start = (int)$matches[1];
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
if ($start < 0 || $start > $end || $start >= $size) {
header('Content-Range: bytes */' . $size);
failMediaStream(416, '请求范围无效');
}
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = $end - $start + 1;
header('Content-Type: ' . $mime);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Content-Disposition: inline');
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$stream = fopen($fullPath, 'rb');
if ($stream === false) failMediaStream(500, '视频文件无法读取');
fseek($stream, $start);
$remaining = $length;
while ($remaining > 0 && !feof($stream)) {
$chunk = fread($stream, min(1024 * 1024, $remaining));
if ($chunk === false || $chunk === '') break;
echo $chunk;
$remaining -= strlen($chunk);
flush();
}
fclose($stream);
+50 -3
View File
@@ -78,7 +78,7 @@ function fetchActionMessageRow(string $messageUid): array
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
@@ -93,7 +93,40 @@ function ensureMessageEditsTable(): void
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
}
function ensurePinnedMessagesTable(): void
{
imAppDb()->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null,'
. '`conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null,'
. '`message_uid` varchar(32) not null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$userId, $conversationType, $targetId, $messageUid, nowTimestampMs()]);
}
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
);
$statement->execute([$userId, $conversationType, $targetId]);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
@@ -222,7 +255,21 @@ if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
try {
if ($action === 'pin' || $action === 'unpin') {
if ($userId === '') respond(400, '缺少操作者ID');
$targetId = trim((string)($payload['target'] ?? ''));
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
if ($targetId === '') respond(400, '缺少会话ID');
$userId = resolveActionUserId($userId);
if ($action === 'pin') {
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
}
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
+112 -16
View File
@@ -213,6 +213,9 @@ function attachmentFallbackText(?array $attachment): string
if ((string)$attachment['type'] === 'image') {
return '[图片]';
}
if ((string)$attachment['type'] === 'video') {
return '[视频]';
}
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
}
@@ -594,6 +597,7 @@ function formatWebGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -641,6 +645,9 @@ function nativeMediaUrlScore(string $url): int
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
$score += 30;
}
if (preg_match('/\.(?:mp4|mov|m4v|webm|avi|mkv|3gp)$/i', $path)) {
$score += 30;
}
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
$score += 5;
}
@@ -691,11 +698,21 @@ function nativeAttachmentName(string $url, string $type): string
return $name;
}
return $type === 'image' ? '图片' : '文件';
return $type === 'image' ? '图片' : ($type === 'video' ? '视频' : '文件');
}
function nativeAttachmentMime(string $url, string $type): string
{
if ($type === 'video') {
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (preg_match('/\.webm$/', $path)) {
return 'video/webm';
}
if (preg_match('/\.(?:mov|m4v)$/', $path)) {
return 'video/quicktime';
}
return 'video/mp4';
}
if ($type !== 'image') {
return '';
}
@@ -730,7 +747,7 @@ function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
}
$contentType = (int)($row['_content_type'] ?? 0);
if ($contentType !== 3 && $contentType !== 5) {
if (!in_array($contentType, [3, 5, 6], true)) {
return null;
}
@@ -739,7 +756,7 @@ function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
return null;
}
$type = $contentType === 3 ? 'image' : 'file';
$type = $contentType === 3 ? 'image' : ($contentType === 6 ? 'video' : 'file');
return [
'type' => $type,
@@ -767,7 +784,7 @@ function nativeDisplayText(string $searchableText, int $contentType, bool $hasAt
{
$text = trim($searchableText);
if (isNativePlaceholderText($text, $contentType)) {
return '';
$text = '';
}
if ($text === '' && !$isRecalled) {
return nativeMessageFallbackText($contentType, $hasAttachment);
@@ -776,6 +793,29 @@ function nativeDisplayText(string $searchableText, int $contentType, bool $hasAt
return $text;
}
function nativeRedPacketFromText(string $text): ?array
{
$decoded = json_decode(trim($text), true);
if (!is_array($decoded)) {
return null;
}
$packet = $decoded['redPackets'] ?? $decoded['redPacket'] ?? null;
if (!is_array($packet)) {
return null;
}
return [
'id' => (string)($packet['id'] ?? ''),
'senderId' => (string)($packet['userId'] ?? $packet['senderId'] ?? ''),
'target' => (string)($packet['target'] ?? $decoded['target'] ?? ''),
'blessing' => trim((string)($packet['blessing'] ?? '')) ?: '恭喜发财,大吉大利',
'status' => (int)($packet['status'] ?? 0),
'expireTime' => (string)($packet['expireTime'] ?? ''),
'createDate' => (string)($packet['createDate'] ?? ''),
];
}
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
{
if ($hasAttachment) {
@@ -784,9 +824,18 @@ function nativeMessageFallbackText(int $contentType, bool $hasAttachment): strin
if ($contentType === 5) {
return '[文件]';
}
if ($contentType === 6) {
return '[视频加载失败]';
}
if ($contentType === 90) {
return '[红包]';
}
if ($contentType === 92) {
return '以上是打招呼的内容';
}
if ($contentType === 93) {
return '你们已经是好友了,可以开始聊天了。';
}
if ($contentType === 105) {
return '[领取了红包]';
}
@@ -892,13 +941,19 @@ function formatNativeGroupMessage(
$messageId = (string)$row['_mid'];
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$contentType = (int)($row['_content_type'] ?? 0);
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
if ($redPacket !== null) {
$contentType = 90;
}
$isRecalled = $contentType === 80;
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment
? (string)$attachment['type']
: (in_array($contentType, [1, 80, 90, 105], true) ? 'text' : 'unknown');
$text = nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
$text = $redPacket !== null
? '[红包]'
: nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
return [
'id' => $messageId,
@@ -910,6 +965,7 @@ function formatNativeGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -929,6 +985,7 @@ function formatNativeGroupMessage(
'contentType' => $contentType,
'text' => attachmentDisplayText($text, $attachment),
'attachment' => $attachment,
'redPacket' => $redPacket,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
'recalled' => $isRecalled,
@@ -1333,6 +1390,10 @@ function formatMessage(
$messageId = (string)$row['_mid'];
$from = (string)$row['_from'];
$contentType = (int)($row['_content_type'] ?? 0);
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
if ($redPacket !== null) {
$contentType = 90;
}
$isRecalled = $contentType === 80;
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
@@ -1340,8 +1401,10 @@ function formatMessage(
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment
? (string)$attachment['type']
: (in_array($contentType, [1, 80, 90, 105], true) ? 'text' : 'unknown');
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
$messageText = $redPacket !== null
? '[红包]'
: nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
@@ -1358,9 +1421,12 @@ function formatMessage(
'target' => $peerId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
'contentType' => $attachment
? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5))
: $contentType,
'text' => $messageText,
'attachment' => $attachment,
'redPacket' => $redPacket,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
'deliveryStatus' => $deliveryStatus,
'recalled' => $isRecalled,
@@ -1510,7 +1576,16 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
'raw' => ['type' => 'single', 'target' => $peerId, 'line' => 0],
'raw' => [
'type' => 'single',
'target' => $peerId,
'line' => 0,
'userId' => (string)($users[$peerId]['_name'] ?? $peerId),
'userUid' => $peerId,
'displayName' => (string)($users[$peerId]['_display_name'] ?? ''),
'mobile' => (string)($users[$peerId]['_mobile'] ?? ''),
'address' => (string)($users[$peerId]['_address'] ?? ''),
],
];
}
@@ -1536,13 +1611,34 @@ function messageSyncResult(
string $userId,
array $messages,
bool $includeConversations,
int $limit
int $limit,
string $targetId = '',
string $conversationType = 'single'
): array {
$result = ['messages' => $messages];
if ($includeConversations) {
$result['conversations'] = loadRecentConversations($db, $userId, $limit);
}
if ($targetId !== '') {
try {
imAppDb()->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
$statement = imAppDb()->prepare(
'select `message_uid` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
);
$statement->execute([$userId, $conversationType, $targetId]);
$result['pinnedMessageUid'] = (string)($statement->fetchColumn() ?: '');
} catch (Throwable) {
$result['pinnedMessageUid'] = '';
}
}
return $result;
}
@@ -1571,7 +1667,7 @@ try {
$userId,
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
if (isNativeGroupTarget($db, $target)) {
@@ -1580,7 +1676,7 @@ try {
$userId,
loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
respond(0, 'success', messageSyncResult(
@@ -1588,7 +1684,7 @@ try {
$userId,
[],
$includeConversations,
$limit
$limit, $target, 'group'
));
}
if (isWebGroupTarget(imAppDb(), $target)) {
@@ -1597,7 +1693,7 @@ try {
$userId,
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
$includeConversations,
$limit
$limit, $target, 'group'
));
}
$targetId = resolveUserId($db, $target);
@@ -1606,7 +1702,7 @@ try {
$userId,
loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
$includeConversations,
$limit
$limit, $targetId, 'single'
));
}
+47 -19
View File
@@ -8,8 +8,9 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
const WEB_MEDIA_PUBLIC_PORT = 18000;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_VIDEO = 3;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
@@ -245,13 +246,32 @@ function storeMessageAttachment(
]);
}
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
return null;
}
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
if ($forwardUrl !== '') {
$parts = parse_url($forwardUrl);
$scheme = strtolower((string)($parts['scheme'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
respond(400, '媒体地址不合法');
}
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
return [
'type' => $messageType,
'url' => $forwardUrl,
'name' => $name,
'mime' => $mime,
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
];
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
@@ -266,11 +286,15 @@ $sender = messageRequestValue($payload, 'sender');
$target = messageRequestValue($payload, 'target');
$text = messageRequestValue($payload, 'text');
$messageType = messageRequestValue($payload, 'messageType', 'text');
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
$mentionedUserIds = is_array($mentionedUserIds)
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
: [];
$attachment = null;
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) {
respond(400, '缺少发送者、接收者或消息内容');
}
@@ -319,15 +343,19 @@ try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
'persistFlag' => 3,
];
if ($mentionedUserIds) {
$payloadBody['mentionedType'] = 1;
$payloadBody['mentionedTarget'] = $mentionedUserIds;
}
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
@@ -364,10 +392,10 @@ try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
+22 -2
View File
@@ -21,11 +21,31 @@ import FavoriteList from './favorites/FavoriteList.vue';
import FavoritePanel from './favorites/FavoritePanel.vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useContactStore } from '@/stores/contacts';
import { useContactStore } from '@/stores/contacts';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const contacts = useContactStore();
const contacts = useContactStore();
useAutoDismiss(
() => conversations.actionError || conversations.actionNotice,
value => {
if ((conversations.actionError || conversations.actionNotice) === value) {
conversations.actionError = '';
conversations.actionNotice = '';
}
}
);
useAutoDismiss(
() => contacts.actionError || contacts.actionInfo,
value => {
if ((contacts.actionError || contacts.actionInfo) === value) {
contacts.actionError = '';
contacts.actionInfo = '';
}
}
);
onMounted(() => {
conversations.refreshRemoteConversations();
+9 -1
View File
@@ -2,7 +2,7 @@
<main class="documented-shell">
<aside class="documented-rail">
<div class="documented-avatar">
<img v-if="session.portrait" :src="session.portrait" alt="">
<img v-if="session.portrait" :src="session.portrait" alt="" @error="useImageProxyFallback($event, session.portrait)">
<span v-else>{{ initials }}</span>
</div>
<div class="documented-rail-copy">
@@ -113,12 +113,20 @@
import { computed, reactive, ref } from 'vue';
import { useSessionStore } from '@/stores/session';
import { appConfig } from '@/config';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
import { useImageProxyFallback } from '@/services/mediaProxy';
const session = useSessionStore();
const passwordDialogOpen = ref(false);
const passwordSaving = ref(false);
const passwordError = ref('');
const passwordNotice = ref('');
useAutoDismiss(() => passwordError.value || passwordNotice.value, value => {
if ((passwordError.value || passwordNotice.value) === value) {
passwordError.value = '';
passwordNotice.value = '';
}
});
const passwordDraft = reactive({
oldPassword: '',
newPassword: '',
+8
View File
@@ -127,6 +127,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { appConfig } from '@/config';
import { useSessionStore } from '@/stores/session';
import { imClient } from '@/services/imClient';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
const session = useSessionStore();
const view = ref('login');
@@ -144,6 +145,13 @@ const registerForm = reactive({
const sendingCode = ref(false);
const info = ref('');
useAutoDismiss(() => session.error, value => {
if (session.error === value) session.error = '';
});
useAutoDismiss(info, value => {
if (info.value === value) info.value = '';
});
const QR_CODE_PREFIX_PC_SESSION = 'wildfirechat://pcsession/';
const qrStatus = ref('idle');
const qrCodeImage = ref('');
+100 -36
View File
@@ -1,5 +1,5 @@
<template>
<section class="chat-panel">
<section class="chat-panel" :class="{ 'has-pinned-message': Boolean(conversations.activePinnedMessage) }">
<header class="chat-header">
<div>
<button
@@ -8,7 +8,7 @@
class="chat-title-button"
@click="openGroupOverview"
>
<img v-if="activeConversation?.portrait" class="chat-title-avatar image-avatar" :src="activeConversation.portrait" alt="">
<img v-if="activeConversation?.portrait" class="chat-title-avatar image-avatar" :src="activeConversation.portrait" alt="" @error="useImageProxyFallback($event, activeConversation.portrait)">
<span v-else class="chat-title-avatar">{{ activeConversation?.avatarText || '?' }}</span>
<span class="chat-title-copy">
<h2>{{ activeConversation?.title || '未选择会话' }}</h2>
@@ -20,7 +20,7 @@
class="chat-title-button"
@click="openSingleProfile"
>
<img v-if="singleProfile.portrait" class="chat-title-avatar image-avatar" :src="singleProfile.portrait" alt="">
<img v-if="singleProfile.portrait" class="chat-title-avatar image-avatar" :src="singleProfile.portrait" alt="" @error="useImageProxyFallback($event, singleProfile.portrait)">
<span v-else class="chat-title-avatar">{{ singleProfile.avatarText }}</span>
<span class="chat-title-copy">
<h2>{{ activeConversation?.title || '未选择会话' }}</h2>
@@ -40,16 +40,36 @@
</div>
</header>
<div v-if="!activeConversation" class="empty-chat">
<div v-if="!activeConversation" class="empty-chat">
<strong>选择一个会话</strong>
<span>真实会话同步完成后,可以在左侧打开聊天</span>
</div>
<MessageList
v-else
</div>
<button
v-if="activeConversation && conversations.activePinnedMessage"
type="button"
class="pinned-message-banner"
title="点击定位该消息"
@click="conversations.focusMessage(conversations.activePinnedMessage)"
>
<span>📌</span>
<strong>置顶消息</strong>
<span class="pinned-message-preview">
<img
v-if="pinnedMessageIsImage"
:src="conversations.activePinnedMessage.attachment.url"
alt="置顶图片"
>
<span>{{ pinnedMessageText }}</span>
</span>
<span class="pinned-message-close" title="取消置顶" @click.stop="conversations.clearPinnedMessage">×</span>
</button>
<MessageList
v-if="activeConversation"
:messages="conversations.activeMessages"
:selection-mode="conversations.selectionMode"
:selected-message-ids="conversations.selectedMessageIds"
:favorite-message-ids="conversations.favoriteMessageIds"
:favorite-message-ids="conversations.favoriteMessageIds"
:pinned-message-id="conversations.activePinnedMessage?.key || ''"
:attention-message-id="conversations.attentionMessageId"
:can-recall-others="canManageGroup && isGroupConversation"
@message-action="conversations.handleMessageAction"
@@ -65,7 +85,8 @@
</div>
<MessageComposer
:disabled="!activeConversation || !session.isAuthenticated || !conversations.canSendActiveConversation"
:quote-message="conversations.quotedMessage"
:quote-message="conversations.quotedMessage"
:mention-users="isGroupConversation ? groupMembers : []"
@cancel-quote="conversations.cancelQuote"
@send="conversations.sendText"
@send-attachment="conversations.sendAttachment"
@@ -162,7 +183,7 @@
</header>
<div v-if="messageDetailGroup?.read?.length" class="message-detail-member-list">
<div v-for="member in messageDetailGroup.read" :key="`read-${member.uid}`" class="message-detail-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
<span class="message-detail-member-main">
<strong>{{ detailMemberName(member) }}</strong>
@@ -182,7 +203,7 @@
</header>
<div v-if="messageDetailGroup?.unread?.length" class="message-detail-member-list">
<div v-for="member in messageDetailGroup.unread" :key="`unread-${member.uid}`" class="message-detail-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
<span class="message-detail-member-main">
<strong>{{ detailMemberName(member) }}</strong>
@@ -202,7 +223,7 @@
</header>
<div v-if="messageDetailGroup?.undelivered?.length" class="message-detail-member-list">
<div v-for="member in messageDetailGroup.undelivered" :key="`undelivered-${member.uid}`" class="message-detail-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
<span class="message-detail-member-main">
<strong>{{ detailMemberName(member) }}</strong>
@@ -226,15 +247,15 @@
<button type="button" class="icon-button" title="关闭" @click="closeSingleProfile">×</button>
</header>
<div class="single-profile-summary">
<img v-if="singleProfile.portrait" class="profile-avatar image-avatar" :src="singleProfile.portrait" alt="">
<img v-if="singleProfile.portrait" class="profile-avatar image-avatar" :src="singleProfile.portrait" alt="" @error="useImageProxyFallback($event, singleProfile.portrait)">
<div v-else class="profile-avatar">{{ singleProfile.avatarText }}</div>
<strong>{{ singleProfile.displayName }}</strong>
</div>
<dl class="group-profile">
<div>
<dt>用户ID</dt>
<dd>{{ singleProfile.uid || '-' }}</dd>
</div>
<div>
<dt>用户ID</dt>
<dd>{{ singleProfile.userId || '-' }}</dd>
</div>
<div>
<dt>昵称</dt>
<dd>{{ singleProfile.rawName || '-' }}</dd>
@@ -275,8 +296,8 @@
<dd>{{ groupDetail?.name || activeConversation?.title || '-' }}</dd>
</div>
<div>
<dt>群ID</dt>
<dd>{{ groupDetail?.groupId || activeConversation?.target || '-' }}</dd>
<dt>群ID</dt>
<dd>{{ groupDisplayId }}</dd>
</div>
<div>
<dt>群说明</dt>
@@ -304,7 +325,7 @@
<div v-if="groupBusy" class="group-loading">正在加载</div>
<div v-else class="group-member-list">
<div v-for="member in groupMembers" :key="member.uid" class="group-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
@@ -345,7 +366,7 @@
:class="{ selected: transferTargetId === member.uid }"
@click="transferTargetId = member.uid"
>
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
@@ -382,7 +403,7 @@
class="group-member-row selectable with-check"
>
<input type="checkbox" :checked="selectedAdminIds.includes(member.uid)" @change="toggleAdmin(member.uid)">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
@@ -422,7 +443,7 @@
class="group-member-row selectable with-check"
>
<input type="checkbox" :checked="selectedKickIds.includes(member.uid)" @change="toggleKick(member.uid)">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="" @error="useImageProxyFallback($event, member.portrait)">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
@@ -494,12 +515,29 @@ import MessageComposer from './MessageComposer.vue';
import { imClient } from '@/services/imClient';
import { useConversationStore } from '@/stores/conversations';
import { useContactStore } from '@/stores/contacts';
import { useSessionStore } from '@/stores/session';
import { useSessionStore } from '@/stores/session';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
import { useImageProxyFallback } from '@/services/mediaProxy';
const conversations = useConversationStore();
const contacts = useContactStore();
const session = useSessionStore();
const activeConversation = computed(() => conversations.activeConversation);
const activeConversation = computed(() => conversations.activeConversation);
const pinnedMessageIsImage = computed(() => {
const message = conversations.activePinnedMessage;
return message?.attachment?.url && (message.type === 'image' || message.attachment.type === 'image');
});
const pinnedMessageText = computed(() => {
const message = conversations.activePinnedMessage;
if (!message) return '';
const text = String(message.text || '').trim();
if (text && text !== '[消息]') return text;
const attachment = message.attachment || {};
if (message.type === 'image' || attachment.type === 'image') return attachment.name || '图片';
if (message.type === 'video' || attachment.type === 'video') return attachment.name || '视频';
if (attachment.url) return attachment.name || '文件';
return '[消息]';
});
const groupMenuOpen = ref(false);
const singleProfileOpen = ref(false);
const singleAliasDraft = ref('');
@@ -529,7 +567,19 @@ const muteDraft = ref({
dailyEnd: '18:00'
});
const muteSaveNotice = ref('');
const muteSaveError = ref('');
const muteSaveError = ref('');
useAutoDismiss(() => singleProfileError.value || singleProfileNotice.value, value => {
if ((singleProfileError.value || singleProfileNotice.value) === value) {
singleProfileError.value = '';
singleProfileNotice.value = '';
}
});
useAutoDismiss(() => muteSaveError.value || muteSaveNotice.value, value => {
if ((muteSaveError.value || muteSaveNotice.value) === value) {
muteSaveError.value = '';
muteSaveNotice.value = '';
}
});
const singlePeerId = computed(() => {
if (!activeConversation.value || isGroupConversation.value) {
return '';
@@ -543,7 +593,7 @@ const singleContact = computed(() => {
}
return contacts.contacts.find(contact => String(contact.uid || contact.id || '') === peerId) || null;
});
const singleProfile = computed(() => {
const singleProfile = computed(() => {
const contact = singleContact.value;
const conversation = activeConversation.value || {};
const rawUser = contact?.rawUser || {};
@@ -556,8 +606,9 @@ const singleProfile = computed(() => {
|| '未知用户';
const rawName = rawUser.displayName || rawUser.name || contact?.title || conversation.title || '';
return {
uid: singlePeerId.value,
return {
uid: singlePeerId.value,
userId: rawUser.name || conversation.raw?.userId || singlePeerId.value,
displayName,
rawName,
mobile: rawUser.mobile || conversation.raw?.mobile || '',
@@ -566,7 +617,17 @@ const singleProfile = computed(() => {
avatarText: String(displayName || '?').slice(0, 1).toUpperCase(),
alias: contact?.alias || ''
};
});
});
const groupDisplayId = computed(() => {
return String(
groupDetail.value?.groupId
|| activeConversation.value?.raw?.groupId
|| activeConversation.value?.raw?.target
|| activeConversation.value?.target
|| activeConversation.value?.id?.replace(/^group-/, '')
|| '-'
);
});
const isGroupConversation = computed(() => {
return ['group', 'channel'].includes(activeConversation.value?.type)
|| activeConversation.value?.raw?.type === 'group';
@@ -1049,9 +1110,9 @@ onBeforeUnmount(() => {
document.removeEventListener('click', closeGroupMenu);
});
watch(
() => activeConversation.value?.id,
() => {
watch(
() => activeConversation.value?.id,
async () => {
closeGroupMenu();
closeSingleProfile();
closeGroupPanel();
@@ -1061,7 +1122,10 @@ watch(
selectedAdminIds.value = [];
selectedKickIds.value = [];
transferTargetId.value = '';
manageSection.value = '';
}
);
manageSection.value = '';
if (isGroupConversation.value) {
await loadGroupMembers();
}
}
, { immediate: true });
</script>
+75 -13
View File
@@ -33,14 +33,27 @@
</button>
</div>
</div>
<textarea
<textarea
ref="textareaEl"
v-model="text"
:disabled="disabled"
placeholder="输入消息"
rows="3"
@keydown.enter.exact.prevent="submit"
></textarea>
rows="3"
@input="handleMentionInput"
@keydown.enter.exact.prevent="submit"
></textarea>
<div v-if="mentionOpen && filteredMentionUsers.length" class="mention-picker">
<button
v-for="member in filteredMentionUsers"
:key="member.uid"
type="button"
@click="selectMention(member)"
>
<img v-if="member.portrait" :src="member.portrait" alt="">
<span v-else>{{ mentionName(member).slice(0, 1) }}</span>
<strong>{{ mentionName(member) }}</strong>
</button>
</div>
<div class="composer-footer">
<span>Enter 发送,Shift + Enter 换行</span>
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
@@ -49,17 +62,21 @@
</template>
<script setup>
import { ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
const props = defineProps({
disabled: {
type: Boolean,
default: false
},
quoteMessage: {
quoteMessage: {
type: Object,
default: null
}
},
mentionUsers: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['send', 'send-attachment', 'cancel-quote']);
@@ -68,23 +85,68 @@ const emojiOpen = ref(false);
const dropActive = ref(false);
const textareaEl = ref(null);
const imageInput = ref(null);
const fileInput = ref(null);
const fileInput = ref(null);
const mentionOpen = ref(false);
const mentionQuery = ref('');
const mentionStart = ref(-1);
const mentionedUserIds = ref([]);
let dragDepth = 0;
const emojiList = [
const emojiList = [
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
'😡', '👍', '🙏', '👏', '💪', '🎉', '❤️', '🔥',
'🌹', '☕', '✅', '⭐', '💰', '🎁', '🤝', '👌'
];
];
const filteredMentionUsers = computed(() => {
const query = mentionQuery.value.toLowerCase();
return props.mentionUsers
.filter(member => member?.uid)
.filter(member => !query || mentionName(member).toLowerCase().includes(query))
.slice(0, 12);
});
function submit() {
const value = text.value.trim();
if (!value) {
return;
}
emit('send', value);
text.value = '';
emit('send', { text: value, mentionedUserIds: [...mentionedUserIds.value] });
text.value = '';
mentionedUserIds.value = [];
mentionOpen.value = false;
emojiOpen.value = false;
}
}
function mentionName(member) {
return String(member?.displayName || member?.name || member?.rawName || member?.uid || '群成员');
}
function handleMentionInput(event) {
const cursor = event?.target?.selectionStart ?? text.value.length;
const beforeCursor = text.value.slice(0, cursor);
const match = beforeCursor.match(/(?:^|\s)@([^\s@]*)$/);
mentionOpen.value = Boolean(match && props.mentionUsers.length);
mentionQuery.value = match?.[1] || '';
mentionStart.value = match ? cursor - match[1].length - 1 : -1;
}
function selectMention(member) {
const element = textareaEl.value;
const cursor = element?.selectionStart ?? text.value.length;
const start = mentionStart.value >= 0 ? mentionStart.value : cursor;
const name = mentionName(member);
text.value = `${text.value.slice(0, start)}@${name} ${text.value.slice(cursor)}`;
if (!mentionedUserIds.value.includes(String(member.uid))) {
mentionedUserIds.value.push(String(member.uid));
}
mentionOpen.value = false;
mentionQuery.value = '';
mentionStart.value = -1;
nextTick(() => {
const nextCursor = start + name.length + 2;
element?.focus();
element?.setSelectionRange(nextCursor, nextCursor);
});
}
function toggleEmojiPicker() {
emojiOpen.value = !emojiOpen.value;
+243 -78
View File
@@ -2,13 +2,14 @@
<div ref="listEl" class="message-list">
<article
v-for="message in messages"
:key="message.id"
class="message-row"
:class="[message.direction, { selected: isSelected(message), attention: isAttention(message), recalled: message.recalled || message.status === 'recalled' }]"
:key="message.id"
class="message-row"
:class="[message.direction, { system: isSystemMessage(message), selected: isSelected(message), attention: isAttention(message), recalled: message.recalled || message.status === 'recalled' }]"
:data-message-key="messageIdentity(message)"
@click="handleMessageClick(message)"
>
<label v-if="selectionMode" class="message-select">
<div v-if="isSystemMessage(message)" class="system-message-pill">{{ systemMessageText(message) }}</div>
<label v-if="selectionMode && !isSystemMessage(message)" class="message-select">
<input
type="checkbox"
:checked="isSelected(message)"
@@ -16,17 +17,17 @@
@change="emit('toggle-select', message)"
>
</label>
<button
v-if="showGroupSender(message)"
<button
v-if="!isSystemMessage(message) && showGroupSender(message)"
type="button"
class="message-sender-avatar"
:title="groupSenderName(message)"
@click.stop="openSenderProfile(message)"
>
<img v-if="message.senderPortrait" :src="message.senderPortrait" alt="">
<img v-if="message.senderPortrait" :src="message.senderPortrait" alt="" @error="useImageProxyFallback($event, message.senderPortrait)">
<span v-else>{{ groupSenderAvatarText(message) }}</span>
</button>
<div class="message-stack">
<div v-if="!isSystemMessage(message)" class="message-stack">
<button
v-if="showGroupSender(message)"
type="button"
@@ -40,15 +41,40 @@
class="message-bubble"
:class="roleBubbleClass(message)"
@contextmenu.prevent="openMenu($event, message)"
>
<button
v-if="isImageMessage(message)"
type="button"
>
<div v-if="isRedPacketMessage(message)" class="red-packet-card">
<div class="red-packet-card-main">
<span class="red-packet-icon">福</span>
<span>
<strong>{{ redPacketBlessing(message) }}</strong>
<small>请在 App 上领取</small>
</span>
</div>
<div class="red-packet-card-footer">中聊红包</div>
</div>
<div v-else-if="isRedPacketReceipt(message)" class="red-packet-receipt">
<span class="red-packet-mini-icon">福</span>
<span>{{ redPacketReceiptText(message) }}</span>
</div>
<button
v-else-if="isImageMessage(message)"
type="button"
class="message-image"
@click.stop="handleImageClick(message)"
>
<img :src="imageDisplayUrl(message.attachment.url)" :alt="message.attachment.name || '图片'">
</button>
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
</button>
<video
v-else-if="isVideoMessage(message)"
class="message-video"
controls
playsinline
preload="metadata"
>
<source :src="videoDisplayUrl(message.attachment.url)" :type="message.attachment.mime || 'video/mp4'">
<source :src="message.attachment.url" :type="message.attachment.mime || 'video/mp4'">
当前浏览器无法播放该视频
</video>
<a
v-else-if="isFileMessage(message)"
class="message-file"
@@ -63,7 +89,7 @@
<small>{{ formatFileSize(message.attachment.size) }}</small>
</span>
</a>
<p v-if="message.text">{{ message.text }}</p>
<p v-if="message.text && !isRedPacketMessage(message) && !isRedPacketReceipt(message)">{{ message.text }}</p>
<time>
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
@@ -109,29 +135,40 @@
class="image-viewer-dialog"
@click.stop
>
<div class="image-viewer-toolbar">
<span class="image-viewer-title">{{ imageViewer.name || '图片' }}</span>
<button type="button" title="缩小" aria-label="缩小" @click="zoomImage(-0.25)">−</button>
<button type="button" title="放大" aria-label="放大" @click="zoomImage(0.25)">+</button>
<button type="button" title="还原" aria-label="还原" @click="resetImageViewer">1:1</button>
<div class="image-viewer-toolbar">
<button type="button" title="旋转" aria-label="旋转" @click="rotateImage">⟳</button>
<button type="button" title="保存" aria-label="保存" @click="saveViewerImage">⇩</button>
<button type="button" title="关闭" aria-label="关闭" @click="closeImageViewer">×</button>
</div>
<div
<div
ref="viewerStage"
class="image-viewer-stage"
:class="{ dragging: imageViewer.dragging, zoomed: imageViewer.scale > 1 }"
@wheel.prevent="handleImageWheel"
@pointerdown.prevent="startImageDrag"
>
<img
>
<button
v-if="viewerImages.length > 1"
type="button"
class="image-viewer-nav image-viewer-prev"
title="上一张"
aria-label="上一张"
@pointerdown.stop
@click.stop="showPreviousImage"
>‹</button>
<img
class="image-viewer-img"
:src="imageViewer.url"
:alt="imageViewer.name || '图片'"
:style="imageViewerStyle"
draggable="false"
>
draggable="false"
>
<button
v-if="viewerImages.length > 1"
type="button"
class="image-viewer-nav image-viewer-next"
title="下一张"
aria-label="下一张"
@pointerdown.stop
@click.stop="showNextImage"
>›</button>
</div>
</div>
</div>
@@ -148,26 +185,26 @@
<button type="button" class="icon-button" title="关闭" @click="closeSenderProfile">×</button>
</header>
<div class="sender-profile-main">
<img v-if="senderProfile.portrait" class="profile-avatar image-avatar" :src="senderProfile.portrait" alt="">
<img v-if="senderProfile.portrait" class="profile-avatar image-avatar" :src="senderProfile.portrait" alt="" @error="useImageProxyFallback($event, senderProfile.portrait)">
<div v-else class="profile-avatar">{{ senderProfile.avatarText }}</div>
<strong>{{ senderProfile.displayName || senderProfile.uid }}</strong>
<span v-if="senderProfile.roleLabel">{{ senderProfile.roleLabel }}</span>
</div>
<dl class="sender-profile-fields">
<div>
<dt>用户ID</dt>
<dd>{{ senderProfile.uid || '-' }}</dd>
</div>
<div>
<dt>昵称</dt>
<dd>{{ senderProfile.rawName || senderProfile.displayName || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ senderProfile.mobile || '未知' }}</dd>
</div>
<div>
<dt>地区</dt>
<div v-if="senderProfile.isContact">
<dt>用户ID</dt>
<dd>{{ senderProfile.userId || '-' }}</dd>
</div>
<div>
<dt>昵称</dt>
<dd>{{ senderProfile.rawName || senderProfile.displayName || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ senderProfile.mobile || '未知' }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ senderProfile.address || '未知' }}</dd>
</div>
</dl>
@@ -211,7 +248,8 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
import { imageDisplayUrl } from '@/services/mediaProxy';
import { useImageProxyFallback, videoDisplayUrl } from '@/services/mediaProxy';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
const props = defineProps({
messages: {
@@ -226,10 +264,14 @@ const props = defineProps({
type: Array,
default: () => []
},
favoriteMessageIds: {
type: Array,
default: () => []
},
favoriteMessageIds: {
type: Array,
default: () => []
},
pinnedMessageId: {
type: String,
default: ''
},
attentionMessageId: {
type: String,
default: ''
@@ -252,10 +294,12 @@ const contextMenu = reactive({
});
const IMAGE_MIN_SCALE = 0.5;
const IMAGE_MAX_SCALE = 6;
const imageViewer = reactive({
const imageViewer = reactive({
visible: false,
url: '',
name: '',
name: '',
sourceUrl: '',
index: 0,
scale: 1,
rotation: 0,
offsetX: 0,
@@ -266,9 +310,10 @@ const imageViewer = reactive({
dragOffsetX: 0,
dragOffsetY: 0
});
const senderProfile = reactive({
visible: false,
uid: '',
const senderProfile = reactive({
visible: false,
uid: '',
userId: '',
displayName: '',
rawName: '',
mobile: '',
@@ -285,11 +330,22 @@ const senderProfile = reactive({
adding: false,
notice: '',
error: ''
});
});
useAutoDismiss(
() => senderProfile.error || senderProfile.notice,
value => {
if ((senderProfile.error || senderProfile.notice) === value) {
senderProfile.error = '';
senderProfile.notice = '';
}
}
);
const imageViewerStyle = computed(() => ({
const imageViewerStyle = computed(() => ({
transform: `translate3d(${imageViewer.offsetX}px, ${imageViewer.offsetY}px, 0) scale(${imageViewer.scale}) rotate(${imageViewer.rotation}deg)`
}));
}));
const viewerImages = computed(() => props.messages.filter(message => isImageMessage(message)));
const menuItems = computed(() => {
if (!contextMenu.message) {
@@ -304,12 +360,14 @@ const menuItems = computed(() => {
{ action: 'detail', label: '详情' }
];
}
const favoriteLabel = isFavorite(contextMenu.message) ? '取消收藏' : '收藏';
const items = [
const favoriteLabel = isFavorite(contextMenu.message) ? '取消收藏' : '收藏';
const pinnedLabel = props.pinnedMessageId === messageIdentity(contextMenu.message) ? '取消置顶' : '置顶';
const items = [
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'forward', label: '转发' },
{ action: 'favorite', label: favoriteLabel },
{ action: 'favorite', label: favoriteLabel },
{ action: 'pin', label: pinnedLabel },
{ action: 'quote', label: '引用' },
{ action: 'select', label: '多选' },
{ action: 'detail', label: '详情' }
@@ -401,9 +459,79 @@ function isImageMessage(message) {
return message?.type === 'image' && message?.attachment?.url;
}
function isFileMessage(message) {
return message?.type === 'file' && message?.attachment?.url;
}
function isFileMessage(message) {
return message?.type === 'file' && message?.attachment?.url;
}
function isVideoMessage(message) {
return (message?.type === 'video' || Number(message?.contentType || 0) === 6) && message?.attachment?.url;
}
function isRedPacketMessage(message) {
return Number(message?.contentType || 0) === 90 || Boolean(redPacketPayload(message));
}
function isRedPacketReceipt(message) {
return Number(message?.contentType || 0) === 105;
}
function isSystemMessage(message) {
return [92, 93].includes(Number(message?.contentType || 0));
}
function systemMessageText(message) {
const contentType = Number(message?.contentType || 0);
if (contentType === 92) {
return '以上是打招呼的内容';
}
if (contentType === 93) {
return '你们已经是好友了,可以开始聊天了。';
}
return String(message?.text || '');
}
function redPacketReceiptText(message) {
const text = String(message?.text || '').trim();
if (text && text !== '[领取了红包]') {
return text;
}
const receiverName = message?.direction === 'out' ? '你' : (message?.senderName || '对方');
const packetSenderName = message?.redPacketSenderName || nearestRedPacketSenderName(message);
return packetSenderName
? `${receiverName}领取了${packetSenderName}的红包`
: `${receiverName}领取了红包`;
}
function nearestRedPacketSenderName(receiptMessage) {
const receiptIndex = props.messages.findIndex(item => messageIdentity(item) === messageIdentity(receiptMessage));
for (let index = receiptIndex - 1; index >= 0; index -= 1) {
const candidate = props.messages[index];
if (isRedPacketMessage(candidate)) {
return candidate?.direction === 'out' ? '你' : (candidate?.senderName || '对方');
}
}
return '';
}
function redPacketPayload(message) {
if (message?.redPacket && typeof message.redPacket === 'object') {
return message.redPacket;
}
const text = String(message?.text || '').trim();
if (!text.startsWith('{')) {
return null;
}
try {
const payload = JSON.parse(text);
return payload?.redPackets || payload?.redPacket || null;
} catch (error) {
return null;
}
}
function redPacketBlessing(message) {
return String(redPacketPayload(message)?.blessing || '恭喜发财,大吉大利');
}
function isGroupMessage(message) {
return ['group', 'channel'].includes(message?.conversationType || '');
@@ -445,8 +573,12 @@ function openSenderProfile(message) {
|| cachedContact?.title
|| groupSenderName(message);
const displayName = contactAlias || groupAlias || rawName || uid;
senderProfile.visible = true;
senderProfile.uid = uid;
senderProfile.visible = true;
senderProfile.uid = uid;
senderProfile.userId = profile.userId
|| profile.name
|| cachedContact?.rawUser?.name
|| '';
senderProfile.displayName = displayName;
senderProfile.rawName = rawName || '';
senderProfile.mobile = profile.mobile || cachedContact?.rawUser?.mobile || '';
@@ -567,22 +699,45 @@ function handleImageClick(message) {
openImageViewer(message);
}
function openImageViewer(message) {
function openImageViewer(message) {
const attachment = message?.attachment || {};
if (!attachment.url) {
return;
}
closeMenu();
imageViewer.visible = true;
imageViewer.url = imageDisplayUrl(attachment.url);
imageViewer.name = attachment.name || 'image';
resetImageViewer();
}
closeMenu();
imageViewer.visible = true;
const index = viewerImages.value.findIndex(item => messageIdentity(item) === messageIdentity(message));
showViewerImage(index >= 0 ? index : 0);
}
function showViewerImage(index) {
const images = viewerImages.value;
if (!images.length) {
closeImageViewer();
return;
}
const normalizedIndex = (index + images.length) % images.length;
const attachment = images[normalizedIndex]?.attachment || {};
imageViewer.index = normalizedIndex;
imageViewer.sourceUrl = attachment.url || '';
imageViewer.url = attachment.url || '';
imageViewer.name = attachment.name || `图片 ${normalizedIndex + 1}`;
resetImageViewer();
}
function showPreviousImage() {
showViewerImage(imageViewer.index - 1);
}
function showNextImage() {
showViewerImage(imageViewer.index + 1);
}
function closeImageViewer() {
function closeImageViewer() {
imageViewer.visible = false;
imageViewer.dragging = false;
}
imageViewer.dragging = false;
imageViewer.sourceUrl = '';
}
function resetImageViewer() {
imageViewer.scale = 1;
@@ -654,12 +809,12 @@ function stopImageDrag() {
imageViewer.dragging = false;
}
function saveViewerImage() {
if (!imageViewer.url) {
function saveViewerImage() {
if (!imageViewer.sourceUrl) {
return;
}
const downloadUrl = new URL('/api/media_download.php', window.location.origin);
downloadUrl.searchParams.set('url', imageViewer.url);
downloadUrl.searchParams.set('url', imageViewer.sourceUrl);
downloadUrl.searchParams.set('name', imageViewer.name || 'image');
const link = document.createElement('a');
@@ -686,7 +841,17 @@ function closeMenu() {
contextMenu.message = null;
}
function closeMenuOnEscape(event) {
function closeMenuOnEscape(event) {
if (imageViewer.visible && event.key === 'ArrowLeft') {
event.preventDefault();
showPreviousImage();
return;
}
if (imageViewer.visible && event.key === 'ArrowRight') {
event.preventDefault();
showNextImage();
return;
}
if (event.key === 'Escape') {
if (senderProfile.visible) {
closeSenderProfile();
+8 -3
View File
@@ -9,7 +9,7 @@
</header>
<div class="contact-profile">
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="">
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="" @error="useImageProxyFallback($event, contact.portrait)">
<div v-else class="profile-avatar">{{ contact.avatarText }}</div>
<div class="profile-main">
<div class="remark-row">
@@ -62,7 +62,9 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
import { useContactStore } from '@/stores/contacts';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
import { useImageProxyFallback } from '@/services/mediaProxy';
const contacts = useContactStore();
const contact = computed(() => contacts.activeContact);
@@ -73,7 +75,10 @@ const address = computed(() => contact.value?.rawUser?.address || '未知');
const editingAlias = ref(false);
const aliasDraft = ref('');
const aliasSaving = ref(false);
const aliasError = ref('');
const aliasError = ref('');
useAutoDismiss(aliasError, value => {
if (aliasError.value === value) aliasError.value = '';
});
watch(contact, () => {
editingAlias.value = false;
+7 -3
View File
@@ -19,7 +19,7 @@
target="_blank"
rel="noreferrer"
>
<img :src="imageDisplayUrl(favorite.attachment.url)" :alt="favorite.attachment.name || '图片'">
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
</a>
<a
v-else-if="isFileFavorite(favorite)"
@@ -59,13 +59,17 @@
import { computed, ref } from 'vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { imageDisplayUrl } from '@/services/mediaProxy';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const favorite = computed(() => conversations.activeFavorite);
const notice = ref('');
const error = ref('');
const error = ref('');
useAutoDismiss(() => error.value || notice.value, value => {
if ((error.value || notice.value) === value) clearFeedback();
});
function clearFeedback() {
notice.value = '';
+3 -2
View File
@@ -23,7 +23,7 @@
:class="{ active: item.id === contacts.activeId }"
@click="contacts.setActiveContact(item.id)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="" @error="useImageProxyFallback($event, item.portrait)">
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
<span class="conversation-copy">
<strong>{{ item.title }}</strong>
@@ -62,7 +62,8 @@
<script setup>
import { computed, ref } from 'vue';
import { useContactStore } from '@/stores/contacts';
import { useContactStore } from '@/stores/contacts';
import { useImageProxyFallback } from '@/services/mediaProxy';
const contacts = useContactStore();
const keyword = ref('');
+66 -17
View File
@@ -24,15 +24,16 @@
<div class="sidebar-scroll">
<button
v-for="item in filteredConversationItems"
:key="item.resultKey"
class="conversation-item"
:class="{ active: isListItemActive(item) }"
@click="selectListItem(item)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
:key="item.resultKey"
class="conversation-item"
:class="{ active: isListItemActive(item), pinned: isPinned(item) }"
@click="selectListItem(item)"
@contextmenu.prevent="openConversationMenu($event, item)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="" @error="useImageProxyFallback($event, item.portrait)">
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
<span class="conversation-copy">
<strong>{{ item.title }}</strong>
<strong><span v-if="isPinned(item)" class="conversation-pin" aria-label="已置顶">⌖</span>{{ item.title }}</strong>
<small>{{ item.subtitle }}</small>
</span>
<span
@@ -42,7 +43,19 @@
>
{{ formatUnread(item.unread) }}
</span>
</button>
</button>
<div
v-if="conversationMenu.item"
class="conversation-context-menu"
:style="{ left: `${conversationMenu.x}px`, top: `${conversationMenu.y}px` }"
@click.stop
>
<button type="button" @click="togglePinned">
{{ isPinned(conversationMenu.item) ? '取消置顶' : '置顶会话' }}
</button>
<button type="button" @click="deleteConversation">删除会话</button>
</div>
<div v-if="!filteredConversationItems.length && searchKeyword" class="empty-list">
<strong>没有匹配结果</strong>
@@ -150,7 +163,8 @@
<script setup>
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue';
import { useConversationStore } from '@/stores/conversations';
import { useContactStore } from '@/stores/contacts';
import { useContactStore } from '@/stores/contacts';
import { useImageProxyFallback } from '@/services/mediaProxy';
const conversations = useConversationStore();
const contacts = useContactStore();
@@ -167,7 +181,8 @@ const broadcastText = ref('');
const channelName = ref('');
const channelAnnouncement = ref('');
const contactKeyword = ref('');
const selectedContactIds = ref([]);
const selectedContactIds = ref([]);
const conversationMenu = ref({ item: null, x: 0, y: 0 });
const actionTitles = {
addContact: '添加联系人',
@@ -249,9 +264,41 @@ const submitDisabled = computed(() => {
return true;
});
onMounted(() => {
contacts.refreshContacts().catch(() => {});
});
onMounted(() => {
contacts.refreshContacts().catch(() => {});
});
function isPinned(item) {
return item?.resultKind === 'conversation' && Number(item.pinnedAt || item.top || 0) > 0;
}
function openConversationMenu(event, item) {
if (item.resultKind !== 'conversation') return;
conversationMenu.value = {
item,
x: Math.min(event.clientX, window.innerWidth - 140),
y: Math.min(event.clientY, window.innerHeight - 60)
};
}
function closeConversationMenu() {
conversationMenu.value = { item: null, x: 0, y: 0 };
}
function togglePinned() {
const item = conversationMenu.value.item;
if (item) conversations.togglePinnedConversation(item.id);
closeConversationMenu();
}
function deleteConversation() {
const item = conversationMenu.value.item;
if (item && window.confirm(`确定删除与“${item.title}”的会话吗?`)) {
conversations.removeConversation(item.id);
conversations.setActionNotice('会话已删除');
}
closeConversationMenu();
}
const ContactPicker = defineComponent({
name: 'ContactPicker',
@@ -513,10 +560,12 @@ async function submitAction() {
}
}
document.addEventListener('click', closeActionMenu);
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
});
document.addEventListener('click', closeActionMenu);
document.addEventListener('click', closeConversationMenu);
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
document.removeEventListener('click', closeConversationMenu);
});
function formatUnread(value) {
const count = Number(value || 0);
+23 -6
View File
@@ -1,7 +1,7 @@
<template>
<aside class="navigation-rail">
<button type="button" class="rail-avatar rail-avatar-button" title="编辑个人资料" @click="openProfileEditor">
<img v-if="session.portrait" :src="session.portrait" alt="">
<img v-if="session.portrait" :src="session.portrait" alt="" @error="useImageProxyFallback($event, session.portrait)">
<span v-else>{{ initials }}</span>
</button>
<button class="rail-button" :class="{ active: navigation.section === 'messages' }" title="消息" @click="navigation.showMessages">💬</button>
@@ -200,9 +200,11 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useSessionStore } from '@/stores/session';
import { useSessionStore } from '@/stores/session';
import { useNavigationStore } from '@/stores/navigation';
import { useContactStore } from '@/stores/contacts';
import { useContactStore } from '@/stores/contacts';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
import { useImageProxyFallback } from '@/services/mediaProxy';
const session = useSessionStore();
const navigation = useNavigationStore();
@@ -251,7 +253,7 @@ const avatarPreviewViewer = reactive({
dragOffsetX: 0,
dragOffsetY: 0
});
const avatarCropper = reactive({
const avatarCropper = reactive({
open: false,
loaded: false,
busy: false,
@@ -268,8 +270,23 @@ const avatarCropper = reactive({
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
dragOffsetY: 0
});
useAutoDismiss(() => settingsError.value || settingsNotice.value, value => {
if ((settingsError.value || settingsNotice.value) === value) {
settingsError.value = '';
settingsNotice.value = '';
}
});
useAutoDismiss(() => profileError.value || profileNotice.value, value => {
if ((profileError.value || profileNotice.value) === value) {
profileError.value = '';
profileNotice.value = '';
}
});
useAutoDismiss(() => avatarCropper.error, value => {
if (avatarCropper.error === value) avatarCropper.error = '';
});
const avatarPreviewStyle = computed(() => ({
transform: `translate3d(${avatarPreviewViewer.offsetX}px, ${avatarPreviewViewer.offsetY}px, 0) scale(${avatarPreviewViewer.scale})`
}));
+18
View File
@@ -0,0 +1,18 @@
import { onBeforeUnmount, watch } from 'vue';
export function useAutoDismiss(source, clear, delay = 3000) {
let timer = null;
watch(source, value => {
window.clearTimeout(timer);
timer = null;
if (!value) return;
const currentValue = value;
timer = window.setTimeout(() => {
clear(currentValue);
timer = null;
}, delay);
}, { flush: 'post' });
onBeforeUnmount(() => window.clearTimeout(timer));
}
+45 -9
View File
@@ -395,6 +395,7 @@ export class HttpImClient {
displayTimestamp: message.displayTimestamp || message.timestamp,
conversationId: conversation.id
}));
messages.pinnedMessageUid = String(response.data?.result?.pinnedMessageUid || '');
if (options.includeConversations && Array.isArray(response.data?.result?.conversations)) {
const remoteConversations = response.data.result.conversations.map(remoteConversation => ({
...remoteConversation,
@@ -405,11 +406,24 @@ export class HttpImClient {
this.emit('conversationsChanged', remoteConversations);
}
const userId = this.currentUser?.userId || '';
const messagesByConversation = readLocalMessages(userId);
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
const messagesByConversation = readLocalMessages(userId);
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
messagesByConversation[conversation.id].pinnedMessageUid = messages.pinnedMessageUid;
writeLocalMessages(messagesByConversation, userId);
return messagesByConversation[conversation.id];
}
return messagesByConversation[conversation.id];
}
async setPinnedMessage(conversation, messageUid = '') {
const action = messageUid ? 'pin' : 'unpin';
const response = await axios.post(appConfig.messageActionApi, {
action,
userId: this.currentUser?.userId || '',
messageUid: messageUid || '1',
target: conversation.target || conversation.raw?.target || '',
conversationType: ['group', 'channel'].includes(conversation.type) ? 'group' : 'single'
}, { withCredentials: false });
if (response.data?.code !== 0) throw new Error(response.data?.message || '置顶失败');
}
hideMessages(conversation, messages) {
const items = Array.isArray(messages) ? messages : [messages];
@@ -516,7 +530,7 @@ export class HttpImClient {
return response.data?.result?.detail || null;
}
async sendTextMessage(conversation, text, initialMessage = null) {
async sendTextMessage(conversation, text, initialMessage = null, options = {}) {
const senderRole = conversation?.role || conversation?.raw?.role || '';
const message = initialMessage || {
id: randomId(),
@@ -539,8 +553,9 @@ export class HttpImClient {
const response = await axios.post(appConfig.sendMessageApi, {
sender: this.currentUser?.userId || '',
target: conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''),
conversationType: conversation.type || conversation.raw?.type || 0,
text
conversationType: conversation.type || conversation.raw?.type || 0,
text,
mentionedUserIds: Array.isArray(options.mentionedUserIds) ? options.mentionedUserIds : []
}, { withCredentials: false });
if (response.data?.code !== 0) {
@@ -573,7 +588,7 @@ export class HttpImClient {
}
}
async sendAttachmentMessage(conversation, { type, file, text = '' }, initialMessage = null) {
async sendAttachmentMessage(conversation, { type, file, text = '' }, initialMessage = null) {
const senderRole = conversation?.role || conversation?.raw?.role || '';
const message = initialMessage || {
id: randomId(),
@@ -641,7 +656,28 @@ export class HttpImClient {
this.emit('messageStatusChanged', failedMessage);
return failedMessage;
}
}
}
async forwardAttachmentMessage(conversation, sourceMessage) {
const attachment = sourceMessage?.attachment || {};
const type = attachment.type || sourceMessage?.type || 'file';
const formData = new FormData();
formData.append('sender', this.currentUser?.userId || '');
formData.append('target', conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''));
formData.append('conversationType', conversation.type || conversation.raw?.type || 0);
formData.append('messageType', type);
formData.append('text', sourceMessage?.text || '');
formData.append('attachmentUrl', attachment.url || '');
formData.append('attachmentName', attachment.name || (type === 'image' ? '转发图片' : (type === 'video' ? '转发视频.mp4' : '转发文件')));
formData.append('attachmentMime', attachment.mime || (type === 'video' ? 'video/mp4' : 'application/octet-stream'));
formData.append('attachmentSize', String(attachment.size || 0));
const response = await axios.post(appConfig.sendMessageApi, formData, { withCredentials: false });
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '转发失败');
}
return response.data?.result || {};
}
getContactList() {
return [];
+18 -1
View File
@@ -4,7 +4,7 @@ function configuredProxyHosts() {
? configured
: String(configured || '').split(/[\s,]+/);
return new Set(
values
['zhiyanim.dfpnm.cn', ...values]
.map(value => String(value || '').trim().toLowerCase().replace(/\.$/, ''))
.filter(Boolean)
);
@@ -28,3 +28,20 @@ export function imageDisplayUrl(value) {
return source;
}
}
export function videoDisplayUrl(value) {
const source = String(value || '').trim();
if (!source || source.startsWith('blob:') || source.startsWith('data:')) return source;
const streamUrl = new URL('/api/media_stream.php', window.location.origin);
streamUrl.searchParams.set('url', source);
return streamUrl.toString();
}
export function useImageProxyFallback(event, value) {
const image = event?.currentTarget;
if (!image) return;
const fallback = imageDisplayUrl(value);
if (!fallback || fallback === value || image.dataset.proxyFallback === '1') return;
image.dataset.proxyFallback = '1';
image.src = fallback;
}
+221 -47
View File
@@ -5,7 +5,9 @@ import { useSessionStore } from '@/stores/session';
import { realtimeNotifications } from '@/services/realtimeNotifications';
const LOCAL_CONVERSATION_STORAGE_KEY = 'qmt-web-im-local-conversations-v2';
const LOCAL_FAVORITE_MESSAGES_KEY = 'qmt-web-im-favorite-messages-v1';
const LOCAL_FAVORITE_MESSAGES_KEY = 'qmt-web-im-favorite-messages-v1';
const LOCAL_PINNED_MESSAGES_KEY = 'qmt-web-im-pinned-messages-v1';
const LOCAL_HIDDEN_CONVERSATIONS_KEY = 'qmt-web-im-hidden-conversations-v1';
function scopedStorageKey(baseKey) {
const session = useSessionStore();
@@ -21,7 +23,7 @@ function readFavoriteMessages() {
}
}
function writeFavoriteMessages(messages) {
function writeFavoriteMessages(messages) {
localStorage.setItem(scopedStorageKey(LOCAL_FAVORITE_MESSAGES_KEY), JSON.stringify(messages));
}
@@ -33,9 +35,47 @@ function readLocalConversations() {
}
}
function writeLocalConversations(conversations) {
localStorage.setItem(scopedStorageKey(LOCAL_CONVERSATION_STORAGE_KEY), JSON.stringify(conversations));
}
function writeLocalConversations(conversations) {
localStorage.setItem(scopedStorageKey(LOCAL_CONVERSATION_STORAGE_KEY), JSON.stringify(conversations));
}
function readHiddenConversationIds() {
try {
return JSON.parse(localStorage.getItem(scopedStorageKey(LOCAL_HIDDEN_CONVERSATIONS_KEY)) || '[]');
} catch (error) {
return [];
}
}
function writeHiddenConversationIds(ids) {
localStorage.setItem(scopedStorageKey(LOCAL_HIDDEN_CONVERSATIONS_KEY), JSON.stringify(ids));
}
function readPinnedMessages() {
try {
return JSON.parse(localStorage.getItem(scopedStorageKey(LOCAL_PINNED_MESSAGES_KEY)) || '{}');
} catch (error) {
return {};
}
}
function writePinnedMessages(messages) {
localStorage.setItem(scopedStorageKey(LOCAL_PINNED_MESSAGES_KEY), JSON.stringify(messages));
}
function sortConversations(conversations) {
return [...conversations].sort((left, right) => {
const leftPinned = Number(left.pinnedAt || left.top || 0);
const rightPinned = Number(right.pinnedAt || right.top || 0);
if (Boolean(leftPinned) !== Boolean(rightPinned)) {
return rightPinned ? 1 : -1;
}
if (leftPinned && rightPinned && leftPinned !== rightPinned) {
return rightPinned - leftPinned;
}
return Number(right.updatedAt || 0) - Number(left.updatedAt || 0);
});
}
function randomId() {
if (window.crypto && window.crypto.randomUUID) {
@@ -79,7 +119,7 @@ function messagePreviewText(message) {
return text || attachmentLabel(message);
}
function quotePrefix(message) {
function quotePrefix(message) {
const text = messagePreviewText(message);
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
}
@@ -139,6 +179,12 @@ function canMarkReadNow(options = {}) {
return true;
}
function forwardedText(message) {
return String(message?.text || '')
.replace(/^转发\s*[::]\s*/u, '')
.trim();
}
function normalizeMessageForDisplay(message) {
if (!message || message.attachment || String(message.text || '').trim()) {
return message;
@@ -146,7 +192,10 @@ function normalizeMessageForDisplay(message) {
const contentType = Number(message.contentType || 0);
const labels = {
3: '[图片加载失败]',
6: '[视频加载失败]',
90: '[红包]',
92: '以上是打招呼的内容',
93: '你们已经是好友了,可以开始聊天了。',
105: '[领取了红包]'
};
if (!labels[contentType]) {
@@ -267,10 +316,13 @@ export const useConversationStore = defineStore('conversations', {
attentionMessageTimer: null,
selectionMode: false,
selectedMessageIds: [],
favoriteMessages: [],
favoriteMessages: [],
pinnedMessagesByConversation: {},
hiddenConversationIds: [],
activeFavoriteKey: '',
actionNotice: '',
actionError: '',
actionNoticeTimer: null,
realtimeSyncTimer: null
}),
getters: {
@@ -296,11 +348,18 @@ export const useConversationStore = defineStore('conversations', {
favoriteMessageIds(state) {
return state.favoriteMessages.map(message => message.key);
},
activeFavorite(state) {
return state.favoriteMessages.find(message => message.key === state.activeFavoriteKey)
|| state.favoriteMessages[0]
|| null;
},
activeFavorite(state) {
return state.favoriteMessages.find(message => message.key === state.activeFavoriteKey)
|| state.favoriteMessages[0]
|| null;
},
activePinnedMessage(state) {
const pinned = state.pinnedMessagesByConversation[state.activeId] || null;
if (!pinned) return null;
const message = (state.visibleMessagesByConversation[state.activeId] || [])
.find(item => messageStableKey(item) === pinned.key);
return message ? { ...pinned, ...message, key: pinned.key } : pinned;
},
selectedMessages(state) {
const selectedIds = new Set(state.selectedMessageIds);
return (state.visibleMessagesByConversation[state.activeId] || [])
@@ -323,6 +382,8 @@ export const useConversationStore = defineStore('conversations', {
loadScopedLocalState() {
this.conversations = [];
this.favoriteMessages = readFavoriteMessages();
this.pinnedMessagesByConversation = readPinnedMessages();
this.hiddenConversationIds = readHiddenConversationIds();
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
this.activeId = '';
this.messagesByConversation = {};
@@ -345,12 +406,14 @@ export const useConversationStore = defineStore('conversations', {
window.clearInterval(this.conversationSyncTimer);
window.clearTimeout(this.syncAfterSendTimer);
window.clearTimeout(this.attentionMessageTimer);
window.clearTimeout(this.actionNoticeTimer);
window.clearTimeout(this.realtimeSyncTimer);
realtimeNotifications.stop();
this.syncTimer = null;
this.conversationSyncTimer = null;
this.syncAfterSendTimer = null;
this.attentionMessageTimer = null;
this.actionNoticeTimer = null;
this.realtimeSyncTimer = null;
this.syncInFlight = false;
this.conversationSyncInFlight = false;
@@ -372,7 +435,8 @@ export const useConversationStore = defineStore('conversations', {
this.attentionMessageId = '';
this.selectionMode = false;
this.selectedMessageIds = [];
this.favoriteMessages = [];
this.favoriteMessages = [];
this.hiddenConversationIds = [];
this.activeFavoriteKey = '';
this.actionNotice = '';
this.actionError = '';
@@ -407,9 +471,11 @@ export const useConversationStore = defineStore('conversations', {
}
this.loadActiveMessages({ allowReadReceipt: true, userAction: true });
},
sendText(text) {
const conversation = this.activeConversation;
if (!conversation || !text.trim()) {
sendText(payload) {
const conversation = this.activeConversation;
const text = typeof payload === 'string' ? payload : String(payload?.text || '');
const mentionedUserIds = Array.isArray(payload?.mentionedUserIds) ? payload.mentionedUserIds : [];
if (!conversation || !text.trim()) {
return;
}
let value = text.trim();
@@ -431,7 +497,7 @@ export const useConversationStore = defineStore('conversations', {
};
this.appendVisibleMessage(conversation.id, message);
this.appendMessage(conversation.id, message);
imClient.sendTextMessage(conversation, value, message);
imClient.sendTextMessage(conversation, value, message, { mentionedUserIds });
conversation.subtitle = value;
conversation.updatedAt = Date.now();
writeLocalConversations(this.conversations);
@@ -513,8 +579,12 @@ export const useConversationStore = defineStore('conversations', {
this.usingDemoData = false;
writeLocalConversations(this.conversations);
},
removeConversation(conversationId) {
this.conversations = this.conversations.filter(conversation => conversation.id !== conversationId);
removeConversation(conversationId) {
if (!this.hiddenConversationIds.includes(conversationId)) {
this.hiddenConversationIds = [...this.hiddenConversationIds, conversationId];
writeHiddenConversationIds(this.hiddenConversationIds);
}
this.conversations = this.conversations.filter(conversation => conversation.id !== conversationId);
const {
[conversationId]: removedMessages,
...messagesByConversation
@@ -541,8 +611,10 @@ export const useConversationStore = defineStore('conversations', {
this.loadActiveMessages();
}
},
applyRemoteConversations(remoteConversations) {
const remote = Array.isArray(remoteConversations) ? remoteConversations : [];
applyRemoteConversations(remoteConversations) {
const hidden = new Set(this.hiddenConversationIds);
const remote = (Array.isArray(remoteConversations) ? remoteConversations : [])
.filter(conversation => !hidden.has(conversation.id));
if (!remote.length && this.conversations.length) {
return;
}
@@ -560,9 +632,8 @@ export const useConversationStore = defineStore('conversations', {
});
});
this.conversations = Array.from(merged.values())
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
.slice(0, 100);
this.conversations = sortConversations(Array.from(merged.values()))
.slice(0, 100);
if (!this.conversations.length) {
this.activeId = '';
@@ -576,10 +647,22 @@ export const useConversationStore = defineStore('conversations', {
if (!this.conversations.find(item => item.id === this.activeId)) {
this.activeId = this.conversations[0].id;
}
writeLocalConversations(this.conversations);
this.loadActiveMessages();
},
appendMessage(conversationId, message) {
writeLocalConversations(this.conversations);
this.loadActiveMessages();
},
togglePinnedConversation(conversationId) {
const conversation = this.conversations.find(item => item.id === conversationId);
if (!conversation) return;
conversation.pinnedAt = Number(conversation.pinnedAt || conversation.top || 0) > 0 ? 0 : Date.now();
conversation.top = conversation.pinnedAt;
this.conversations = sortConversations(this.conversations);
writeLocalConversations(this.conversations);
},
appendMessage(conversationId, message) {
if (message?.direction === 'in' && this.hiddenConversationIds.includes(conversationId)) {
this.hiddenConversationIds = this.hiddenConversationIds.filter(id => id !== conversationId);
writeHiddenConversationIds(this.hiddenConversationIds);
}
if (!this.messagesByConversation[conversationId]) {
this.messagesByConversation = {
...this.messagesByConversation,
@@ -772,6 +855,25 @@ export const useConversationStore = defineStore('conversations', {
markRead,
includeConversations: Boolean(options.includeConversations)
});
const remotePinnedKey = String(messages.pinnedMessageUid || '');
if (remotePinnedKey) {
const pinnedMessage = messages.find(item => messageStableKey(item) === remotePinnedKey);
this.pinnedMessagesByConversation = {
...this.pinnedMessagesByConversation,
[syncedConversationId]: pinnedMessage
? {
key: remotePinnedKey,
id: remotePinnedKey,
text: messagePreviewText(pinnedMessage),
type: pinnedMessage.type || 'text',
attachment: pinnedMessage.attachment ? { ...pinnedMessage.attachment } : null,
senderName: pinnedMessage.senderName || pinnedMessage.from || '',
timestamp: Number(pinnedMessage.displayTimestamp || pinnedMessage.timestamp || Date.now())
}
: { key: remotePinnedKey, id: remotePinnedKey, text: '[置顶消息]' }
};
writePinnedMessages(this.pinnedMessagesByConversation);
}
const editedIncomingMessages = incomingEditedMessages(previousMessages, messages);
this.pruneOptimisticMessages(syncedConversationId, messages);
this.messagesByConversation = {
@@ -875,10 +977,20 @@ export const useConversationStore = defineStore('conversations', {
setActionNotice(message) {
this.actionNotice = message;
this.actionError = '';
window.clearTimeout(this.actionNoticeTimer);
this.actionNoticeTimer = window.setTimeout(() => {
this.actionNotice = '';
this.actionNoticeTimer = null;
}, 3000);
},
setActionError(message) {
this.actionError = message;
this.actionNotice = '';
window.clearTimeout(this.actionNoticeTimer);
this.actionNoticeTimer = window.setTimeout(() => {
this.actionError = '';
this.actionNoticeTimer = null;
}, 3000);
},
focusMessage(message) {
const key = messageStableKey(message);
@@ -905,8 +1017,10 @@ export const useConversationStore = defineStore('conversations', {
this.deleteMessages([message]);
} else if (action === 'forward') {
this.startForward([message]);
} else if (action === 'favorite') {
this.toggleFavorite(message);
} else if (action === 'favorite') {
this.toggleFavorite(message);
} else if (action === 'pin') {
this.togglePinnedMessage(message);
} else if (action === 'detail') {
await this.openMessageDetail(message);
} else if (action === 'quote') {
@@ -981,7 +1095,7 @@ export const useConversationStore = defineStore('conversations', {
}
this.setActionNotice(messages.length > 1 ? '已删除所选消息' : '已删除');
},
toggleFavorite(message) {
toggleFavorite(message) {
const key = messageStableKey(message);
if (!key) {
return;
@@ -1015,8 +1129,47 @@ export const useConversationStore = defineStore('conversations', {
this.setActionNotice('已收藏');
}
this.favoriteMessages = this.favoriteMessages.slice(0, 200);
writeFavoriteMessages(this.favoriteMessages);
},
writeFavoriteMessages(this.favoriteMessages);
},
async togglePinnedMessage(message) {
const conversationId = message?.conversationId || this.activeId;
const key = messageStableKey(message);
if (!conversationId || !key) return;
const current = this.pinnedMessagesByConversation[conversationId];
const next = { ...this.pinnedMessagesByConversation };
if (current && current.key === key) {
await imClient.setPinnedMessage(this.activeConversation, '').catch(error => this.setActionError(error.message || '取消置顶失败'));
delete next[conversationId];
this.setActionNotice('已取消置顶');
} else {
try {
await imClient.setPinnedMessage(this.activeConversation, key);
} catch (error) {
this.setActionError(error.message || '置顶失败');
return;
}
next[conversationId] = {
key,
id: key,
text: messagePreviewText(message),
type: message.type || 'text',
attachment: message.attachment ? { ...message.attachment } : null,
senderName: message.senderName || message.from || '',
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
};
this.setActionNotice('消息已置顶');
}
this.pinnedMessagesByConversation = next;
writePinnedMessages(next);
},
async clearPinnedMessage() {
await imClient.setPinnedMessage(this.activeConversation, '').catch(error => this.setActionError(error.message || '取消置顶失败'));
const next = { ...this.pinnedMessagesByConversation };
delete next[this.activeId];
this.pinnedMessagesByConversation = next;
writePinnedMessages(next);
this.setActionNotice('已取消置顶');
},
setActiveFavorite(key) {
this.activeFavoriteKey = String(key || '');
},
@@ -1072,8 +1225,8 @@ export const useConversationStore = defineStore('conversations', {
deleteSelectedMessages() {
this.deleteMessages(this.selectedMessages);
},
startForward(messages) {
this.forwardingMessages = messages.filter(message => message?.text);
startForward(messages) {
this.forwardingMessages = messages.filter(message => message?.text || message?.attachment?.url);
if (!this.forwardingMessages.length) {
this.setActionError('没有可转发的消息');
}
@@ -1136,22 +1289,43 @@ export const useConversationStore = defineStore('conversations', {
this.setActionError(error.message || '编辑失败');
}
},
async completeForward(targetConversationId) {
async completeForward(targetConversationId) {
const target = this.conversations.find(conversation => conversation.id === targetConversationId);
if (!target || !this.forwardingMessages.length) {
return;
}
const text = this.forwardingMessages
.map(message => `转发:${message.text || ''}`)
.join('\n\n');
try {
await this.sendTextToConversation(target, text);
this.forwardingMessages = [];
this.clearSelection();
this.setActionNotice('已转发');
} catch (error) {
this.setActionError(error.message || '转发失败');
}
const messages = [...this.forwardingMessages];
let successCount = 0;
const errors = [];
for (const message of messages) {
try {
if (message.attachment?.url) {
await imClient.forwardAttachmentMessage(target, message);
} else if (message.text) {
const text = forwardedText(message);
if (!text) {
throw new Error('消息内容为空');
}
const sentMessage = await this.sendTextToConversation(target, text);
if (sentMessage?.status === 'failed') {
throw new Error(sentMessage.error || '转发失败');
}
}
successCount += 1;
} catch (error) {
errors.push(error.message || '转发失败');
}
}
if (successCount > 0) {
this.forwardingMessages = [];
this.clearSelection();
this.refreshRemoteConversations();
}
if (errors.length) {
this.setActionError(`成功 ${successCount} 条,失败 ${errors.length} 条:${errors[0]}`);
} else {
this.setActionNotice(`已转发 ${successCount} 条`);
}
},
async recallMessage(message) {
const conversation = this.activeConversation;
+309 -61
View File
@@ -1249,10 +1249,48 @@ button {
text-align: left;
}
.conversation-item.active,
.conversation-item:hover {
background: #eaf3f1;
}
.conversation-item.active,
.conversation-item:hover {
background: #eaf3f1;
}
.conversation-item.pinned {
background: rgba(15, 118, 110, 0.055);
}
.conversation-pin {
display: inline-block;
margin-right: 5px;
color: #0f766e;
font-size: 12px;
transform: rotate(-25deg);
}
.conversation-context-menu {
position: fixed;
z-index: 1200;
min-width: 124px;
padding: 6px;
border: 1px solid #d7e0e8;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
}
.conversation-context-menu button {
width: 100%;
padding: 9px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: #1f2937;
text-align: left;
cursor: pointer;
}
.conversation-context-menu button:hover {
background: #eef7f6;
}
.favorite-item {
width: 100%;
@@ -1465,7 +1503,7 @@ button {
box-shadow: 0 0 0 2px #f7fafc;
}
.chat-panel {
.chat-panel {
position: relative;
min-width: 0;
min-height: 0;
@@ -1473,8 +1511,12 @@ button {
display: grid;
grid-template-rows: 72px minmax(0, 1fr) auto auto;
overflow: hidden;
background: #eef2f5;
}
background: #eef2f5;
}
.chat-panel.has-pinned-message {
grid-template-rows: 72px auto minmax(0, 1fr) auto auto;
}
.contact-panel {
min-width: 0;
@@ -2252,7 +2294,7 @@ button {
max-width: 100%;
}
.message-row.out .message-bubble {
.message-row.out .message-bubble {
background: var(--bubble-out);
border-color: #bfe4dc;
}
@@ -2305,38 +2347,230 @@ button {
margin-top: 8px;
}
.message-image img {
.message-image img {
display: block;
max-width: 100%;
max-height: 320px;
object-fit: contain;
}
}
.pinned-message-banner {
width: calc(100% - 28px);
height: 42px;
min-height: 42px;
margin: 8px 14px;
align-self: center;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #d5e2df;
border-radius: 9px;
background: #f7fbfa;
color: #52606d;
text-align: left;
cursor: pointer;
}
.pinned-message-banner strong {
color: #0f766e;
white-space: nowrap;
}
.pinned-message-preview {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
overflow: hidden;
}
.pinned-message-preview > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pinned-message-preview img {
width: 28px;
height: 28px;
flex: 0 0 28px;
border-radius: 4px;
object-fit: cover;
}
.pinned-message-close {
padding: 0 4px;
color: #748091;
font-size: 20px;
}
.message-row.system {
justify-content: center;
padding-top: 5px;
padding-bottom: 5px;
}
.system-message-pill {
max-width: min(78%, 620px);
padding: 5px 12px;
border-radius: 5px;
background: #c8c8c8;
color: #fff;
font-size: 12px;
line-height: 1.45;
text-align: center;
}
.message-video {
display: block;
width: min(360px, 68vw);
max-height: 360px;
border-radius: 7px;
background: #111;
}
.mention-picker {
position: absolute;
left: 18px;
bottom: 112px;
z-index: 20;
width: min(320px, calc(100% - 36px));
max-height: 260px;
overflow-y: auto;
padding: 6px;
border: 1px solid #d7e0e8;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 32px rgb(15 23 42 / 18%);
}
.mention-picker button {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 0;
border-radius: 7px;
background: transparent;
text-align: left;
}
.mention-picker button:hover {
background: #eef7f5;
}
.mention-picker img,
.mention-picker button > span {
width: 32px;
height: 32px;
border-radius: 7px;
object-fit: cover;
}
.mention-picker button > span {
display: grid;
place-items: center;
background: #dceaf3;
color: #23445c;
}
.red-packet-card {
width: min(320px, 68vw);
margin: -10px -12px -8px;
overflow: hidden;
border-radius: 7px;
background: #ff9f32;
color: #fff;
}
.red-packet-card-main {
display: flex;
align-items: center;
gap: 13px;
padding: 20px 18px 17px;
}
.red-packet-card-main > span:last-child {
display: grid;
gap: 7px;
}
.red-packet-card-main strong {
font-size: 17px;
font-weight: 700;
}
.red-packet-card-main small {
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
}
.red-packet-icon,
.red-packet-mini-icon {
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 3px;
background: #d92f27;
color: #ffd968;
font-weight: 800;
}
.red-packet-icon {
width: 34px;
height: 44px;
font-size: 17px;
}
.red-packet-card-footer {
padding: 9px 15px;
background: #fff;
color: #8a8f94;
font-size: 12px;
}
.red-packet-receipt {
display: flex;
align-items: center;
gap: 7px;
color: #69747d;
}
.red-packet-mini-icon {
width: 22px;
height: 28px;
font-size: 11px;
}
.image-viewer-backdrop {
.image-viewer-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(12, 18, 26, 0.58);
}
.image-viewer-dialog {
width: min(880px, calc(100vw - 48px));
height: min(640px, calc(100vh - 48px));
min-width: 360px;
min-height: 280px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px);
padding: 20px;
background: rgba(7, 12, 20, 0.82);
backdrop-filter: blur(6px);
}
.image-viewer-dialog {
width: min(920px, calc(100vw - 64px));
height: min(660px, calc(100vh - 64px));
min-width: 320px;
min-height: 260px;
max-width: calc(100vw - 64px);
max-height: calc(100vh - 64px);
display: grid;
grid-template-rows: 52px minmax(0, 1fr);
overflow: hidden;
resize: both;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: #101820;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.36);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px;
background: #0b1119;
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.56);
}
.image-viewer-toolbar {
@@ -2350,17 +2584,6 @@ button {
background: rgba(24, 33, 47, 0.94);
}
.image-viewer-title {
min-width: 0;
flex: 1;
overflow: hidden;
color: #f8fafc;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-viewer-toolbar button {
width: 36px;
height: 36px;
@@ -2379,39 +2602,64 @@ button {
background: rgba(255, 255, 255, 0.14);
}
.image-viewer-stage {
.image-viewer-stage {
position: relative;
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
overflow: hidden;
background: #0c121a;
cursor: default;
touch-action: none;
}
overflow: hidden;
background: #0c121a;
cursor: default;
}
.image-viewer-nav {
position: absolute;
top: 50%;
z-index: 2;
width: 52px;
height: 52px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
background: rgba(18, 27, 39, 0.76);
color: #fff;
font-size: 42px;
font-weight: 300;
line-height: 1;
transform: translateY(-50%);
cursor: pointer;
backdrop-filter: blur(5px);
}
.image-viewer-nav:hover {
background: rgba(41, 57, 75, 0.94);
}
.image-viewer-prev {
left: 18px;
}
.image-viewer-next {
right: 18px;
}
.image-viewer-stage.zoomed {
cursor: grab;
}
.image-viewer-stage.dragging {
cursor: grabbing;
}
.image-viewer-img {
max-width: calc(100% - 32px);
max-height: calc(100% - 32px);
object-fit: contain;
.image-viewer-img {
position: absolute;
top: 20px;
left: 90px;
width: calc(100% - 180px);
height: calc(100% - 40px);
max-width: none;
max-height: none;
object-fit: contain;
transform-origin: center center;
transition: transform 0.08s ease-out;
transition: transform 0.12s ease-out;
user-select: none;
pointer-events: none;
}
.image-viewer-stage.dragging .image-viewer-img {
transition: none;
}
.sender-profile-backdrop {
position: fixed;
inset: 0;