更新优化
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
+20
-4
@@ -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);
|
||||
|
||||
Vendored
+50
-3
@@ -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');
|
||||
|
||||
Vendored
+110
-16
@@ -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
@@ -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;
|
||||
Vendored
+1
-1
@@ -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>
|
||||
+828
-440
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`
|
||||
}));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user