1.没置顶的消息,到最上面去了 2.无法转发图片 3.用户ID 是一串字母+数字 4.视频无法显示 5.消息无法置顶 6.可以引用文字,无法艾特用户 7.图片点开只能显示一半 8.转发不能多选,只能一个个转发 9.转发有转发提示,需取消

This commit is contained in:
annali168290-ux
2026-10-11 00:26:07 +07:00
parent cf66f936e5
commit 1cabf661ca
29 changed files with 3906 additions and 1579 deletions
Binary file not shown.
Binary file not shown.
+71 -13
View File
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$nativeGroup = $groupStatement->fetch();
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
$memberStatement->execute([$groupId, $userId]);
$nativeMember = $memberStatement->fetch();
if (!$nativeGroup || !$nativeMember) {
respond(403, '你不在该群组中');
}
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
$ownerStatement->execute([$groupId]);
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
return ['group' => [
'groupId' => $groupId,
'publicId' => (string)($nativeGroup['id'] ?? ''),
'name' => (string)($nativeGroup['_name'] ?? $groupId),
'category' => 'group',
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
'welcomeText' => '',
'ownerUid' => $ownerId,
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
]];
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
$myAlias = (string)($member['alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['group_id'],
'group' => [
'groupId' => (string)$group['group_id'],
'publicId' => (string)($group['id'] ?? ''),
'name' => (string)$group['name'],
'category' => (string)$group['category'],
'announcement' => (string)($group['announcement'] ?? ''),
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
];
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
$membership->execute([$groupId, $userId]);
if (!$membership->fetchColumn()) {
respond(403, '你不在该群组中');
}
$nativeStatement = $imDb->prepare(
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
);
$nativeStatement->execute([$groupId]);
$nativeMembers = $nativeStatement->fetchAll();
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
$uid = (string)$member['user_id'];
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
$alias = trim((string)($member['_alias'] ?? ''));
$type = (int)($member['_type'] ?? 0);
return [
'uid' => $uid,
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
'alias' => $alias,
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $nativeMembers)];
}
$statement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
+20 -4
View File
@@ -6,6 +6,18 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
function mediaProxyEnv(string $name, string $default = ''): string
{
if (function_exists('webImEnv')) {
return (string)webImEnv($name, $default);
}
$value = getenv($name);
if ($value === false || trim((string)$value) === '') {
return $default;
}
return trim((string)$value);
}
function failMediaProxy(int $status, string $message): never
{
http_response_code($status);
@@ -18,12 +30,13 @@ function failMediaProxy(int $status, string $message): never
function mediaProxyAllowedHosts(): array
{
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
$hosts = array_map(
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
preg_split('/[,\s]+/', $configured) ?: []
);
$hosts[] = 'zhiyanim.dfpnm.cn';
return array_values(array_unique(array_filter($hosts)));
}
@@ -53,9 +66,12 @@ function validatedMediaProxyUrl(string $url): array
function mediaProxyCacheRoot(): string
{
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
if ($root === '' || !is_dir($root) || !is_writable($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
failMediaProxy(503, '媒体缓存目录不可用');
}
}
return $root;
@@ -63,7 +79,7 @@ function mediaProxyCacheRoot(): string
function mediaProxyPreferredIps(): array
{
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
$ips = [];
foreach ($configured as $ip) {
$ip = trim($ip);
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
function failMediaStream(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$url = trim((string)($_GET['url'] ?? ''));
if ($url === '') {
failMediaStream(400, '缺少视频地址');
}
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failMediaStream(400, '视频地址不合法');
}
$root = realpath(WEB_MEDIA_STREAM_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failMediaStream(404, '视频文件不存在');
}
$size = (int)filesize($fullPath);
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
if (!str_starts_with($mime, 'video/')) {
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
}
$start = 0;
$end = max(0, $size - 1);
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
if ($matches[1] !== '') $start = (int)$matches[1];
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
if ($start < 0 || $start > $end || $start >= $size) {
header('Content-Range: bytes */' . $size);
failMediaStream(416, '请求范围无效');
}
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = $end - $start + 1;
header('Content-Type: ' . $mime);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Content-Disposition: inline');
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$stream = fopen($fullPath, 'rb');
if ($stream === false) failMediaStream(500, '视频文件无法读取');
fseek($stream, $start);
$remaining = $length;
while ($remaining > 0 && !feof($stream)) {
$chunk = fread($stream, min(1024 * 1024, $remaining));
if ($chunk === false || $chunk === '') break;
echo $chunk;
$remaining -= strlen($chunk);
flush();
}
fclose($stream);
+66 -3
View File
@@ -78,7 +78,7 @@ function fetchActionMessageRow(string $messageUid): array
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
@@ -93,7 +93,55 @@ function ensureMessageEditsTable(): void
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
}
function ensurePinnedMessagesTable(): void
{
$db = imAppDb();
$db->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,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
}
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([
$userId,
$conversationType,
$targetId,
$messageUid,
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
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 +270,22 @@ 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') {
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
}
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
+1724 -900
View File
File diff suppressed because it is too large Load Diff
+111 -24
View File
@@ -8,8 +8,9 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
const WEB_MEDIA_PUBLIC_PORT = 18000;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_VIDEO = 3;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
@@ -245,13 +246,32 @@ function storeMessageAttachment(
]);
}
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
return null;
}
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
if ($forwardUrl !== '') {
$parts = parse_url($forwardUrl);
$scheme = strtolower((string)($parts['scheme'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
respond(400, '媒体地址不合法');
}
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
return [
'type' => $messageType,
'url' => $forwardUrl,
'name' => $name,
'mime' => $mime,
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
];
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
@@ -266,11 +286,15 @@ $sender = messageRequestValue($payload, 'sender');
$target = messageRequestValue($payload, 'target');
$text = messageRequestValue($payload, 'text');
$messageType = messageRequestValue($payload, 'messageType', 'text');
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
$mentionedUserIds = is_array($mentionedUserIds)
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
: [];
$attachment = null;
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) {
respond(400, '缺少发送者、接收者或消息内容');
}
@@ -285,11 +309,70 @@ try {
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
. ' limit 1'
);
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
}
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
$imDb = imDb();
$nativeGroupStatement = $imDb->prepare(
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
);
$nativeGroupStatement->execute([$target]);
$nativeMemberStatement = $imDb->prepare(
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
);
$nativeMemberStatement->execute([$target, $sender]);
$nativeMemberType = $nativeMemberStatement->fetchColumn();
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($mentionedUserIds) {
$payloadBody['mentionedType'] = 1;
$payloadBody['mentionedTarget'] = $mentionedUserIds;
}
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
'payload' => $payloadBody,
'userMessage' => true,
]), '原生群消息发送失败');
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $timestamp,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => '',
]);
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
@@ -319,15 +402,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 +451,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,
+33 -22
View File
File diff suppressed because one or more lines are too long
+71 -13
View File
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$nativeGroup = $groupStatement->fetch();
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
$memberStatement->execute([$groupId, $userId]);
$nativeMember = $memberStatement->fetch();
if (!$nativeGroup || !$nativeMember) {
respond(403, '你不在该群组中');
}
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
$ownerStatement->execute([$groupId]);
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
return ['group' => [
'groupId' => $groupId,
'publicId' => (string)($nativeGroup['id'] ?? ''),
'name' => (string)($nativeGroup['_name'] ?? $groupId),
'category' => 'group',
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
'welcomeText' => '',
'ownerUid' => $ownerId,
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
]];
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
$myAlias = (string)($member['alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['group_id'],
'group' => [
'groupId' => (string)$group['group_id'],
'publicId' => (string)($group['id'] ?? ''),
'name' => (string)$group['name'],
'category' => (string)$group['category'],
'announcement' => (string)($group['announcement'] ?? ''),
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
];
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
$membership->execute([$groupId, $userId]);
if (!$membership->fetchColumn()) {
respond(403, '你不在该群组中');
}
$nativeStatement = $imDb->prepare(
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
);
$nativeStatement->execute([$groupId]);
$nativeMembers = $nativeStatement->fetchAll();
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
$uid = (string)$member['user_id'];
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
$alias = trim((string)($member['_alias'] ?? ''));
$type = (int)($member['_type'] ?? 0);
return [
'uid' => $uid,
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
'alias' => $alias,
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $nativeMembers)];
}
$statement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
function failMediaStream(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$url = trim((string)($_GET['url'] ?? ''));
if ($url === '') {
failMediaStream(400, '缺少视频地址');
}
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failMediaStream(400, '视频地址不合法');
}
$root = realpath(WEB_MEDIA_STREAM_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failMediaStream(404, '视频文件不存在');
}
$size = (int)filesize($fullPath);
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
if (!str_starts_with($mime, 'video/')) {
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
}
$start = 0;
$end = max(0, $size - 1);
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
if ($matches[1] !== '') $start = (int)$matches[1];
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
if ($start < 0 || $start > $end || $start >= $size) {
header('Content-Range: bytes */' . $size);
failMediaStream(416, '请求范围无效');
}
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = $end - $start + 1;
header('Content-Type: ' . $mime);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Content-Disposition: inline');
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$stream = fopen($fullPath, 'rb');
if ($stream === false) failMediaStream(500, '视频文件无法读取');
fseek($stream, $start);
$remaining = $length;
while ($remaining > 0 && !feof($stream)) {
$chunk = fread($stream, min(1024 * 1024, $remaining));
if ($chunk === false || $chunk === '') break;
echo $chunk;
$remaining -= strlen($chunk);
flush();
}
fclose($stream);
+22 -6
View File
@@ -97,26 +97,41 @@ function ensureMessageEditsTable(): void
function ensurePinnedMessagesTable(): void
{
imAppDb()->exec(
$db = imAppDb();
$db->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,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
}
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid): void
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): 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`)'
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$userId, $conversationType, $targetId, $messageUid, nowTimestampMs()]);
$statement->execute([
$userId,
$conversationType,
$targetId,
$messageUid,
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
nowTimestampMs(),
]);
}
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
@@ -263,7 +278,8 @@ try {
if ($targetId === '') respond(400, '缺少会话ID');
$userId = resolveActionUserId($userId);
if ($action === 'pin') {
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
+16 -4
View File
@@ -597,6 +597,7 @@ function formatWebGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -964,6 +965,7 @@ function formatNativeGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -1064,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
}
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 4, $limit), 400);
$scanLimit = min(max($limit * 4, $limit), 5000);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
@@ -1624,16 +1626,26 @@ function messageSyncResult(
'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,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
imAppDb()->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
$statement = imAppDb()->prepare(
'select `message_uid` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
'select `message_uid`, `message_json` 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() ?: '');
$pinnedRow = $statement->fetch();
$result['pinnedMessageUid'] = (string)($pinnedRow['message_uid'] ?? '');
$pinnedMessage = json_decode((string)($pinnedRow['message_json'] ?? ''), true);
$result['pinnedMessage'] = is_array($pinnedMessage) ? $pinnedMessage : null;
} catch (Throwable) {
$result['pinnedMessageUid'] = '';
$result['pinnedMessage'] = null;
}
}
@@ -1643,7 +1655,7 @@ function messageSyncResult(
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
$markRead = payloadBool($payload['markRead'] ?? false);
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
+112 -28
View File
@@ -8,8 +8,9 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
const WEB_MEDIA_PUBLIC_PORT = 18000;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_VIDEO = 3;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
@@ -245,13 +246,32 @@ function storeMessageAttachment(
]);
}
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
return null;
}
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
if ($forwardUrl !== '') {
$parts = parse_url($forwardUrl);
$scheme = strtolower((string)($parts['scheme'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
respond(400, '媒体地址不合法');
}
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
return [
'type' => $messageType,
'url' => $forwardUrl,
'name' => $name,
'mime' => $mime,
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
];
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
@@ -266,11 +286,15 @@ $sender = messageRequestValue($payload, 'sender');
$target = messageRequestValue($payload, 'target');
$text = messageRequestValue($payload, 'text');
$messageType = messageRequestValue($payload, 'messageType', 'text');
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
$mentionedUserIds = is_array($mentionedUserIds)
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
: [];
$attachment = null;
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) {
respond(400, '缺少发送者、接收者或消息内容');
}
@@ -285,11 +309,69 @@ try {
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
. ' limit 1'
);
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
}
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
$imDb = imDb();
$nativeGroupStatement = $imDb->prepare(
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
);
$nativeGroupStatement->execute([$target]);
$nativeMemberStatement = $imDb->prepare(
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
);
$nativeMemberStatement->execute([$target, $sender]);
$nativeMemberType = $nativeMemberStatement->fetchColumn();
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($mentionedUserIds) {
$payloadBody['mentionedType'] = 1;
$payloadBody['mentionedTarget'] = $mentionedUserIds;
}
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
'payload' => $payloadBody,
]), '原生群消息发送失败');
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $timestamp,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => '',
]);
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
@@ -319,15 +401,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'];
@@ -344,8 +430,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
'payload' => $payloadBody,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
@@ -364,10 +449,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,
@@ -390,8 +475,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
'payload' => $payloadBody,
]), '发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
+47
View File
@@ -2455,6 +2455,53 @@ button {
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;
-o-object-fit: cover;
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;
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="favicon.ico"><script src="config/runtime-config.production.js"></script><script defer="defer" src="js/chunk-vendors.d3ec3fbc.js"></script><script defer="defer" src="js/app.1158390e.js"></script><link href="css/app.3ff3e457.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.21e75967.js"></script><link href="css/app.0488f79e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
-1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+71 -13
View File
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$nativeGroup = $groupStatement->fetch();
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
$memberStatement->execute([$groupId, $userId]);
$nativeMember = $memberStatement->fetch();
if (!$nativeGroup || !$nativeMember) {
respond(403, '你不在该群组中');
}
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
$ownerStatement->execute([$groupId]);
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
return ['group' => [
'groupId' => $groupId,
'publicId' => (string)($nativeGroup['id'] ?? ''),
'name' => (string)($nativeGroup['_name'] ?? $groupId),
'category' => 'group',
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
'welcomeText' => '',
'ownerUid' => $ownerId,
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
]];
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
$myAlias = (string)($member['alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['group_id'],
'group' => [
'groupId' => (string)$group['group_id'],
'publicId' => (string)($group['id'] ?? ''),
'name' => (string)$group['name'],
'category' => (string)$group['category'],
'announcement' => (string)($group['announcement'] ?? ''),
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
];
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
$membership->execute([$groupId, $userId]);
if (!$membership->fetchColumn()) {
respond(403, '你不在该群组中');
}
$nativeStatement = $imDb->prepare(
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
);
$nativeStatement->execute([$groupId]);
$nativeMembers = $nativeStatement->fetchAll();
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
$uid = (string)$member['user_id'];
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
$alias = trim((string)($member['_alias'] ?? ''));
$type = (int)($member['_type'] ?? 0);
return [
'uid' => $uid,
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
'alias' => $alias,
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $nativeMembers)];
}
$statement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
+22 -6
View File
@@ -97,26 +97,41 @@ function ensureMessageEditsTable(): void
function ensurePinnedMessagesTable(): void
{
imAppDb()->exec(
$db = imAppDb();
$db->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,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
}
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid): void
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): 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`)'
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$userId, $conversationType, $targetId, $messageUid, nowTimestampMs()]);
$statement->execute([
$userId,
$conversationType,
$targetId,
$messageUid,
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
nowTimestampMs(),
]);
}
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
@@ -263,7 +278,8 @@ try {
if ($targetId === '') respond(400, '缺少会话ID');
$userId = resolveActionUserId($userId);
if ($action === 'pin') {
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
+14 -4
View File
@@ -1066,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
}
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 4, $limit), 400);
$scanLimit = min(max($limit * 4, $limit), 5000);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
@@ -1626,16 +1626,26 @@ function messageSyncResult(
'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,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
imAppDb()->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
$statement = imAppDb()->prepare(
'select `message_uid` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
'select `message_uid`, `message_json` 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() ?: '');
$pinnedRow = $statement->fetch();
$result['pinnedMessageUid'] = (string)($pinnedRow['message_uid'] ?? '');
$pinnedMessage = json_decode((string)($pinnedRow['message_json'] ?? ''), true);
$result['pinnedMessage'] = is_array($pinnedMessage) ? $pinnedMessage : null;
} catch (Throwable) {
$result['pinnedMessageUid'] = '';
$result['pinnedMessage'] = null;
}
}
@@ -1645,7 +1655,7 @@ function messageSyncResult(
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
$markRead = payloadBool($payload['markRead'] ?? false);
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
+65 -9
View File
@@ -309,11 +309,69 @@ try {
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
. ' limit 1'
);
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
}
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
$imDb = imDb();
$nativeGroupStatement = $imDb->prepare(
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
);
$nativeGroupStatement->execute([$target]);
$nativeMemberStatement = $imDb->prepare(
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
);
$nativeMemberStatement->execute([$target, $sender]);
$nativeMemberType = $nativeMemberStatement->fetchColumn();
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($mentionedUserIds) {
$payloadBody['mentionedType'] = 1;
$payloadBody['mentionedTarget'] = $mentionedUserIds;
}
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
'payload' => $payloadBody,
]), '原生群消息发送失败');
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $timestamp,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => '',
]);
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
@@ -372,8 +430,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
'payload' => $payloadBody,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
@@ -418,8 +475,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
'payload' => $payloadBody,
]), '发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
+53 -27
View File
@@ -86,7 +86,7 @@
<MessageComposer
:disabled="!activeConversation || !session.isAuthenticated || !conversations.canSendActiveConversation"
:quote-message="conversations.quotedMessage"
:mention-users="isGroupConversation ? groupMembers : []"
:mention-users="isGroupConversation ? mentionCandidates : []"
@cancel-quote="conversations.cancelQuote"
@send="conversations.sendText"
@send-attachment="conversations.sendAttachment"
@@ -295,7 +295,7 @@
<dt>群名</dt>
<dd>{{ groupDetail?.name || activeConversation?.title || '-' }}</dd>
</div>
<div>
<div v-if="groupDisplayId">
<dt>群ID</dt>
<dd>{{ groupDisplayId }}</dd>
</div>
@@ -619,14 +619,7 @@ const singleProfile = computed(() => {
};
});
const groupDisplayId = computed(() => {
return String(
groupDetail.value?.groupId
|| activeConversation.value?.raw?.groupId
|| activeConversation.value?.raw?.target
|| activeConversation.value?.target
|| activeConversation.value?.id?.replace(/^group-/, '')
|| '-'
);
return String(groupDetail.value?.publicId || '');
});
const isGroupConversation = computed(() => {
return ['group', 'channel'].includes(activeConversation.value?.type)
@@ -637,8 +630,26 @@ const currentGroupRole = computed(() => {
});
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
const mentionCandidates = computed(() => {
const members = new Map();
groupMembers.value.forEach(member => {
const uid = String(member?.uid || '').trim();
if (uid && uid !== String(session.userId || '')) members.set(uid, member);
});
conversations.activeMessages.forEach(message => {
const uid = String(message?.from || '').trim();
if (!uid || uid === 'me' || uid === session.userId || members.has(uid)) return;
members.set(uid, {
uid,
displayName: message?.senderName || uid,
portrait: message?.senderPortrait || message?.portrait || '',
role: message?.senderRole || 'member'
});
});
return Array.from(members.values());
});
const messageDetail = computed(() => conversations.messageDetail || null);
const messageDetailGroup = computed(() => messageDetail.value?.group || null);
const isMessageDetailGroup = computed(() => {
@@ -795,19 +806,25 @@ function closeGroupPanel() {
clearMuteSaveFeedback();
}
async function loadGroupDetail() {
async function loadGroupDetail() {
if (!session.userId || !activeConversation.value?.target) {
return null;
}
groupBusy.value = true;
try {
groupDetail.value = await imClient.getGroupDetail(session.userId, activeConversation.value.target);
const requestedConversationId = activeConversation.value.id;
const requestedTarget = activeConversation.value.target;
groupBusy.value = true;
try {
const detail = await imClient.getGroupDetail(session.userId, requestedTarget);
if (activeConversation.value?.id !== requestedConversationId) return null;
groupDetail.value = detail;
return groupDetail.value;
} catch (error) {
conversations.setActionError(error.message || '群资料加载失败');
if (activeConversation.value?.id === requestedConversationId) {
conversations.setActionError(error.message || '群资料加载失败');
}
return null;
} finally {
groupBusy.value = false;
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
}
}
@@ -826,19 +843,26 @@ async function openGroupMembers() {
await loadGroupMembers();
}
async function loadGroupMembers() {
groupBusy.value = true;
try {
groupMembers.value = await imClient.getGroupMembers(session.userId, activeConversation.value.target);
async function loadGroupMembers({ silent = false } = {}) {
const requestedConversationId = activeConversation.value?.id;
const requestedTarget = activeConversation.value?.target;
if (!session.userId || !requestedConversationId || !requestedTarget) return [];
groupBusy.value = true;
try {
const members = await imClient.getGroupMembers(session.userId, requestedTarget);
if (activeConversation.value?.id !== requestedConversationId) return [];
groupMembers.value = members;
selectedAdminIds.value = groupMembers.value
.filter(member => member.role === 'admin')
.map(member => member.uid);
return groupMembers.value;
} catch (error) {
conversations.setActionError(error.message || '群员加载失败');
} catch (error) {
if (!silent && activeConversation.value?.id === requestedConversationId) {
conversations.setActionError(error.message || '群员加载失败');
}
return [];
} finally {
groupBusy.value = false;
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
}
}
@@ -1113,7 +1137,9 @@ onBeforeUnmount(() => {
watch(
() => activeConversation.value?.id,
async () => {
closeGroupMenu();
conversations.actionError = '';
conversations.actionNotice = '';
closeGroupMenu();
closeSingleProfile();
closeGroupPanel();
groupDetail.value = null;
@@ -1124,7 +1150,7 @@ watch(
transferTargetId.value = '';
manageSection.value = '';
if (isGroupConversation.value) {
await loadGroupMembers();
await loadGroupMembers({ silent: true });
}
}
, { immediate: true });
+19 -3
View File
@@ -17,8 +17,9 @@
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
</div>
<div class="composer-tools">
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
<button type="button" title="艾特群成员" :disabled="disabled || !mentionUsers.length" @click="openMentionPicker">@</button>
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
@@ -123,12 +124,27 @@ function mentionName(member) {
function handleMentionInput(event) {
const cursor = event?.target?.selectionStart ?? text.value.length;
const beforeCursor = text.value.slice(0, cursor);
const match = beforeCursor.match(/(?:^|\s)@([^\s@]*)$/);
const match = beforeCursor.match(/@([^\s@]*)$/);
mentionOpen.value = Boolean(match && props.mentionUsers.length);
mentionQuery.value = match?.[1] || '';
mentionStart.value = match ? cursor - match[1].length - 1 : -1;
}
function openMentionPicker() {
if (!props.mentionUsers.length) return;
const element = textareaEl.value;
const start = element?.selectionStart ?? text.value.length;
const end = element?.selectionEnd ?? start;
text.value = `${text.value.slice(0, start)}@${text.value.slice(end)}`;
mentionStart.value = start;
mentionQuery.value = '';
mentionOpen.value = true;
nextTick(() => {
element?.focus();
element?.setSelectionRange(start + 1, start + 1);
});
}
function selectMention(member) {
const element = textareaEl.value;
const cursor = element?.selectionStart ?? text.value.length;
+14 -8
View File
@@ -67,12 +67,12 @@
<video
v-else-if="isVideoMessage(message)"
class="message-video"
:src="videoDisplayUrl(message.attachment.url)"
controls
playsinline
preload="metadata"
@error="useVideoOriginalFallback($event, message.attachment.url)"
>
<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
@@ -85,11 +85,11 @@
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ message.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(message.attachment.size) }}</small>
</span>
</a>
<p v-if="message.text && !isRedPacketMessage(message) && !isRedPacketReceipt(message)">{{ message.text }}</p>
<strong>{{ displayFileName(message) }}</strong>
<small>{{ formatFileSize(message.attachment.size) }}</small>
</span>
</a>
<p v-if="message.text && !isFileMessage(message) && !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>
@@ -248,7 +248,7 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
import { useImageProxyFallback, videoDisplayUrl } from '@/services/mediaProxy';
import { useImageProxyFallback, useVideoOriginalFallback, videoDisplayUrl } from '@/services/mediaProxy';
import { useAutoDismiss } from '@/composables/useAutoDismiss';
const props = defineProps({
@@ -463,6 +463,12 @@ function isFileMessage(message) {
return message?.type === 'file' && message?.attachment?.url;
}
function displayFileName(message) {
const text = String(message?.text || '').trim();
const attachmentName = String(message?.attachment?.name || '').trim();
return text || attachmentName || '文件';
}
function isVideoMessage(message) {
return (message?.type === 'video' || Number(message?.contentType || 0) === 6) && message?.attachment?.url;
}
+103 -3
View File
@@ -156,7 +156,13 @@ function mergeSyncedMessages(localMessages, remoteMessages, conversationId, user
if (message.status === 'sent') {
return !hasRemoteMatch(message) && now - Number(message.timestamp || 0) < 30000;
}
return message.status === 'sending' || message.status === 'failed';
if (message.status === 'failed') {
// Failed optimistic messages are only transient feedback. Keeping them in
// localStorage forever makes an old server error reappear every time the
// conversation is opened, even after later messages send successfully.
return now - Number(message.timestamp || 0) < 3000;
}
return message.status === 'sending';
});
return [...visibleRemoteMessages, ...pendingLocalMessages].sort((a, b) => {
@@ -395,7 +401,9 @@ export class HttpImClient {
displayTimestamp: message.displayTimestamp || message.timestamp,
conversationId: conversation.id
}));
messages.hasPinnedMessageState = Object.prototype.hasOwnProperty.call(response.data?.result || {}, 'pinnedMessageUid');
messages.pinnedMessageUid = String(response.data?.result?.pinnedMessageUid || '');
messages.pinnedMessage = response.data?.result?.pinnedMessage || null;
if (options.includeConversations && Array.isArray(response.data?.result?.conversations)) {
const remoteConversations = response.data.result.conversations.map(remoteConversation => ({
...remoteConversation,
@@ -409,21 +417,113 @@ export class HttpImClient {
const messagesByConversation = readLocalMessages(userId);
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
messagesByConversation[conversation.id].pinnedMessageUid = messages.pinnedMessageUid;
messagesByConversation[conversation.id].pinnedMessage = messages.pinnedMessage;
messagesByConversation[conversation.id].hasPinnedMessageState = messages.hasPinnedMessageState;
writeLocalMessages(messagesByConversation, userId);
return messagesByConversation[conversation.id];
}
async setPinnedMessage(conversation, messageUid = '') {
async setPinnedMessage(conversation, messageUid = '', message = null) {
const isGroup = ['group', 'channel'].includes(conversation?.type)
|| ['group', 'channel', 1, '1'].includes(conversation?.raw?.type);
if (isGroup) {
if (messageUid && message) {
return this.addGroupPinnedMessage(conversation, message);
}
const serverPinId = String(message?.serverPinId || message?.pinId || '').trim();
if (!serverPinId) {
throw new Error('缺少置顶记录ID,请刷新会话后重试');
}
return this.removeGroupPinnedMessage(serverPinId);
}
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'
conversationType: ['group', 'channel'].includes(conversation.type) ? 'group' : 'single',
message: message ? {
id: String(message.messageUid || message.id || messageUid),
messageUid: String(message.messageUid || message.id || messageUid),
text: String(message.text || ''),
type: message.type || 'text',
attachment: message.attachment || null,
senderName: message.senderName || message.from || '',
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
} : null
}, { withCredentials: false });
if (response.data?.code !== 0) throw new Error(response.data?.message || '置顶失败');
}
async fetchGroupPinnedMessages(conversation) {
const groupId = String(conversation?.target || conversation?.raw?.target || '').trim();
if (!groupId) return [];
const url = `${appConfig.appServer}/groupTopMessage/gets`;
const response = await axios.get(url, {
params: { groupId },
headers: this.getAppAuthHeaders(url),
withCredentials: false
});
const payload = response.data || {};
if (payload.code !== undefined && Number(payload.code) !== 0) {
throw new Error(payload.message || '置顶消息加载失败');
}
const result = payload.result ?? payload.data ?? payload;
return Array.isArray(result) ? result : [];
}
async addGroupPinnedMessage(conversation, message) {
const groupId = String(conversation?.target || conversation?.raw?.target || '').trim();
const messageId = String(message?.messageId || message?.messageUid || message?.id || '').trim();
if (!groupId || !messageId) throw new Error('缺少群ID或消息ID');
const searchableContent = String(message?.text || '').trim()
|| (message?.type === 'image' ? '[图片]' : message?.type === 'video' ? '[视频]' : '[消息]');
const rawConversationType = Number(conversation?.raw?.type);
const conversationType = Number.isFinite(rawConversationType) && rawConversationType > 0
? rawConversationType
: 1;
const url = `${appConfig.appServer}/groupTopMessage/add`;
const response = await axios.post(url, {
groupId,
userId: message?.from || this.currentUser?.userId || '',
messageId,
messageType: conversationType,
messageText: JSON.stringify({
...message,
messageId,
from: message?.from || this.currentUser?.userId || '',
conversation: {
type: conversationType,
target: groupId,
line: Number(conversation?.raw?.line || 0)
},
content: { searchableContent }
})
}, {
headers: this.getAppAuthHeaders(url),
withCredentials: false
});
const payload = response.data || {};
if (payload.code !== undefined && Number(payload.code) !== 0) {
const detail = payload.message || payload.msg || payload.result?.message || '置顶失败';
throw new Error(payload.code !== undefined ? `${detail}(错误码 ${payload.code})` : detail);
}
return payload.result ?? payload.data ?? payload;
}
async removeGroupPinnedMessage(pinId) {
const url = `${appConfig.appServer}/groupTopMessage/remove/?id=${encodeURIComponent(pinId)}`;
const response = await axios.post(url, {}, {
headers: this.getAppAuthHeaders(url),
withCredentials: false
});
const payload = response.data || {};
if (payload.code !== undefined && Number(payload.code) !== 0) {
throw new Error(payload.message || '取消置顶失败');
}
return payload.result ?? payload.data ?? payload;
}
hideMessages(conversation, messages) {
const items = Array.isArray(messages) ? messages : [messages];
+9
View File
@@ -37,6 +37,15 @@ export function videoDisplayUrl(value) {
return streamUrl.toString();
}
export function useVideoOriginalFallback(event, value) {
const video = event?.currentTarget;
const source = String(value || '').trim();
if (!video || !source || video.dataset.originalFallback === '1') return;
video.dataset.originalFallback = '1';
video.src = source;
video.load();
}
export function useImageProxyFallback(event, value) {
const image = event?.currentTarget;
if (!image) return;
+96 -11
View File
@@ -63,6 +63,39 @@ function writePinnedMessages(messages) {
localStorage.setItem(scopedStorageKey(LOCAL_PINNED_MESSAGES_KEY), JSON.stringify(messages));
}
function parseNativePinnedMessage(item, messages = []) {
if (!item) return null;
const messageId = String(item.messageId || item.messageUid || '').trim();
const matched = messages.find(message => [message.messageId, message.messageUid, message.id]
.filter(Boolean)
.some(value => String(value) === messageId));
let encoded = {};
try {
encoded = typeof item.messageText === 'string' ? JSON.parse(item.messageText) : (item.messageText || {});
} catch (error) {
encoded = {};
}
const text = String(
encoded?.content?.searchableContent
|| encoded?.messageContent?.searchableContent
|| encoded?.text
|| matched?.text
|| '[消息]'
);
return {
...(matched || {}),
key: messageId || `group-pin-${item.id}`,
id: messageId || `group-pin-${item.id}`,
messageUid: messageId || matched?.messageUid || '',
serverPinId: String(item.id || ''),
text,
type: matched?.type || encoded?.type || 'text',
attachment: matched?.attachment || encoded?.attachment || null,
senderName: matched?.senderName || encoded?.senderName || encoded?.from || '',
timestamp: Number(matched?.displayTimestamp || matched?.timestamp || item.updateTime || item.createTime || Date.now())
};
}
function sortConversations(conversations) {
return [...conversations].sort((left, right) => {
const leftPinned = Number(left.pinnedAt || left.top || 0);
@@ -318,6 +351,7 @@ export const useConversationStore = defineStore('conversations', {
selectedMessageIds: [],
favoriteMessages: [],
pinnedMessagesByConversation: {},
groupPinnedSyncAt: {},
hiddenConversationIds: [],
activeFavoriteKey: '',
actionNotice: '',
@@ -383,6 +417,7 @@ export const useConversationStore = defineStore('conversations', {
this.conversations = [];
this.favoriteMessages = readFavoriteMessages();
this.pinnedMessagesByConversation = readPinnedMessages();
this.groupPinnedSyncAt = {};
this.hiddenConversationIds = readHiddenConversationIds();
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
this.activeId = '';
@@ -851,13 +886,40 @@ export const useConversationStore = defineStore('conversations', {
const syncedConversationId = conversation.id;
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
try {
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
const messages = await imClient.fetchRemoteMessages(conversation, 1000, {
markRead,
includeConversations: Boolean(options.includeConversations)
});
const isGroupConversation = ['group', 'channel'].includes(conversation.type)
|| ['group', 'channel', 1, '1'].includes(conversation.raw?.type);
const shouldSyncGroupPin = isGroupConversation
&& Date.now() - Number(this.groupPinnedSyncAt[syncedConversationId] || 0) >= 3000;
if (shouldSyncGroupPin) {
try {
const nativePins = await imClient.fetchGroupPinnedMessages(conversation);
this.groupPinnedSyncAt = {
...this.groupPinnedSyncAt,
[syncedConversationId]: Date.now()
};
const nextPinned = { ...this.pinnedMessagesByConversation };
if (nativePins.length) {
nextPinned[syncedConversationId] = parseNativePinnedMessage(nativePins[0], messages);
} else {
delete nextPinned[syncedConversationId];
}
this.pinnedMessagesByConversation = nextPinned;
writePinnedMessages(nextPinned);
} catch (error) {
console.warn('群置顶消息同步失败', error);
}
}
const remotePinnedKey = String(messages.pinnedMessageUid || '');
if (remotePinnedKey) {
const pinnedMessage = messages.find(item => messageStableKey(item) === remotePinnedKey);
if (!isGroupConversation && remotePinnedKey) {
const pinnedMessage = messages.find(item => messageStableKey(item) === remotePinnedKey)
|| messages.pinnedMessage
|| (this.pinnedMessagesByConversation[syncedConversationId]?.key === remotePinnedKey
? this.pinnedMessagesByConversation[syncedConversationId]
: null);
this.pinnedMessagesByConversation = {
...this.pinnedMessagesByConversation,
[syncedConversationId]: pinnedMessage
@@ -1138,16 +1200,16 @@ export const useConversationStore = defineStore('conversations', {
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.pinnedMessagesByConversation = next;
writePinnedMessages(next);
this.setActionNotice('已取消置顶');
} else {
try {
await imClient.setPinnedMessage(this.activeConversation, key);
await imClient.setPinnedMessage(this.activeConversation, '', current);
} catch (error) {
this.setActionError(error.message || '置顶失败');
return;
this.setActionError(`已在本地取消,服务器同步失败:${error.message || '取消置顶失败'}`);
}
} else {
next[conversationId] = {
key,
id: key,
@@ -1157,18 +1219,41 @@ export const useConversationStore = defineStore('conversations', {
senderName: message.senderName || message.from || '',
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
};
this.pinnedMessagesByConversation = next;
writePinnedMessages(next);
this.setActionNotice('消息已置顶');
try {
await imClient.setPinnedMessage(this.activeConversation, key, message);
const nativePins = await imClient.fetchGroupPinnedMessages(this.activeConversation).catch(() => []);
if (nativePins.length) {
const refreshed = parseNativePinnedMessage(nativePins[0], this.activeMessages);
this.pinnedMessagesByConversation = {
...this.pinnedMessagesByConversation,
[conversationId]: refreshed
};
writePinnedMessages(this.pinnedMessagesByConversation);
}
} catch (error) {
const rollback = { ...this.pinnedMessagesByConversation };
delete rollback[conversationId];
this.pinnedMessagesByConversation = rollback;
writePinnedMessages(rollback);
this.setActionError(`置顶失败:${error.message || '服务器同步失败'}`);
}
}
this.pinnedMessagesByConversation = next;
writePinnedMessages(next);
},
async clearPinnedMessage() {
await imClient.setPinnedMessage(this.activeConversation, '').catch(error => this.setActionError(error.message || '取消置顶失败'));
const current = this.pinnedMessagesByConversation[this.activeId] || null;
const next = { ...this.pinnedMessagesByConversation };
delete next[this.activeId];
this.pinnedMessagesByConversation = next;
writePinnedMessages(next);
this.setActionNotice('已取消置顶');
try {
await imClient.setPinnedMessage(this.activeConversation, '', current);
} catch (error) {
this.setActionError(`已在本地取消,服务器同步失败:${error.message || '取消置顶失败'}`);
}
},
setActiveFavorite(key) {
this.activeFavoriteKey = String(key || '');