1.没置顶的消息,到最上面去了 2.无法转发图片 3.用户ID 是一串字母+数字 4.视频无法显示 5.消息无法置顶 6.可以引用文字,无法艾特用户 7.图片点开只能显示一半 8.转发不能多选,只能一个个转发 9.转发有转发提示,需取消
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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`'
|
||||
|
||||
@@ -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,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
File diff suppressed because it is too large
Load Diff
+111
-24
@@ -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
File diff suppressed because one or more lines are too long
Vendored
+71
-13
@@ -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`'
|
||||
|
||||
Vendored
+71
@@ -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);
|
||||
Vendored
+22
-6
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+16
-4
@@ -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);
|
||||
|
||||
Vendored
+112
-28
@@ -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'] : '';
|
||||
|
||||
@@ -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;
|
||||
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.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>
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
+1003
-476
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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`'
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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'] : '';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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 || '');
|
||||
|
||||
Reference in New Issue
Block a user