1.没置顶的消息,到最上面去了 2.无法转发图片 3.用户ID 是一串字母+数字 4.视频无法显示 5.消息无法置顶 6.可以引用文字,无法艾特用户 7.图片点开只能显示一半 8.转发不能多选,只能一个个转发 9.转发有转发提示,需取消
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -429,7 +429,33 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
$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) {
|
||||
@@ -445,6 +471,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
return [
|
||||
'group' => [
|
||||
'groupId' => (string)$group['group_id'],
|
||||
'publicId' => (string)($group['id'] ?? ''),
|
||||
'name' => (string)$group['name'],
|
||||
'category' => (string)$group['category'],
|
||||
'announcement' => (string)($group['announcement'] ?? ''),
|
||||
@@ -462,7 +489,38 @@ 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, '你不在该群组中');
|
||||
$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(
|
||||
|
||||
@@ -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);
|
||||
@@ -95,6 +95,54 @@ function ensureMessageEditsTable(): void
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$row = fetchActionMessageRow($messageUid);
|
||||
@@ -223,6 +271,21 @@ if ($action === '' || $messageUid <= 0) {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
+842
-18
@@ -132,7 +132,7 @@ function fetchMessageRows(PDO $db, array $messageIds): array
|
||||
|
||||
$messages = [];
|
||||
foreach ($idsByTable as $table => $ids) {
|
||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
||||
$statement = $db->prepare($sql);
|
||||
$statement->execute($ids);
|
||||
@@ -213,6 +213,9 @@ function attachmentFallbackText(?array $attachment): string
|
||||
if ((string)$attachment['type'] === 'image') {
|
||||
return '[图片]';
|
||||
}
|
||||
if ((string)$attachment['type'] === 'video') {
|
||||
return '[视频]';
|
||||
}
|
||||
|
||||
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
|
||||
}
|
||||
@@ -232,12 +235,21 @@ function messagePreviewText(?array $message): string
|
||||
return '';
|
||||
}
|
||||
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
||||
$contentType = (int)($message['contentType'] ?? 0);
|
||||
$messageType = (string)($message['type'] ?? '');
|
||||
$placeholderIsImage = $contentType === 3 || $messageType === 'image';
|
||||
$text = trim((string)($message['text'] ?? ''));
|
||||
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||
$text = '';
|
||||
}
|
||||
if ($placeholderIsImage && ($text === '[图片]' || $text === '图片')) {
|
||||
$text = '';
|
||||
}
|
||||
if ($attachment) {
|
||||
$text = trim((string)($message['text'] ?? ''));
|
||||
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
||||
}
|
||||
|
||||
return (string)($message['text'] ?? '');
|
||||
return $text;
|
||||
}
|
||||
|
||||
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
||||
@@ -405,6 +417,42 @@ function isWebGroupTarget(PDO $appDb, string $targetId): bool
|
||||
return (bool)$statement->fetchColumn();
|
||||
}
|
||||
|
||||
function nativeGroupRoleFromType(mixed $type): string
|
||||
{
|
||||
$value = (int)$type;
|
||||
if ($value === 2) {
|
||||
return 'owner';
|
||||
}
|
||||
if ($value === 1) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
function isNativeGroupTarget(PDO $db, string $targetId): bool
|
||||
{
|
||||
if ($targetId === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statement = $db->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$statement->execute([$targetId]);
|
||||
|
||||
return (bool)$statement->fetchColumn();
|
||||
}
|
||||
|
||||
function nativeGroupRole(PDO $db, string $userId, string $groupId): string
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $userId]);
|
||||
$type = $statement->fetchColumn();
|
||||
|
||||
return $type === false ? '' : nativeGroupRoleFromType($type);
|
||||
}
|
||||
|
||||
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||
@@ -422,6 +470,34 @@ function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $time
|
||||
$statement->execute([$groupId, $userId, $timestamp, $now]);
|
||||
}
|
||||
|
||||
function markNativeGroupRead(PDO $db, string $userId, string $groupId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `id`, `_dt` from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||
. ' order by `_dt` desc limit 1'
|
||||
);
|
||||
$statement->execute([$userId, $groupId]);
|
||||
$row = $statement->fetch();
|
||||
|
||||
if ($row) {
|
||||
if ($timestamp > (int)$row['_dt']) {
|
||||
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
|
||||
$update->execute([$timestamp, (int)$row['id']]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$insert = $db->prepare(
|
||||
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 1, 0, ?, ?)'
|
||||
);
|
||||
$insert->execute([$userId, $groupId, $timestamp]);
|
||||
}
|
||||
|
||||
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||
{
|
||||
$statement = $appDb->prepare(
|
||||
@@ -434,6 +510,50 @@ function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||
return is_string($role) && $role !== '' ? $role : '';
|
||||
}
|
||||
|
||||
function loadNativeGroupReadStateMap(PDO $db, string $userId, array $groupIds): array
|
||||
{
|
||||
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||
if ($userId === '' || !$groupIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' group by `_target`'
|
||||
);
|
||||
$statement->execute(array_merge([$userId], $groupIds));
|
||||
|
||||
$readAtByGroup = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$readAtByGroup[(string)$row['_target']] = (int)$row['_read_at'];
|
||||
}
|
||||
|
||||
return $readAtByGroup;
|
||||
}
|
||||
|
||||
function fetchNativeGroupMemberMap(PDO $db, string $groupId, array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||
if ($groupId === '' || !$userIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_alias`, `_type`, `_dt`, `_create_dt` from `t_group_member`'
|
||||
. ' where `_gid` = ? and `_mid` in (' . placeholders($userIds) . ')'
|
||||
);
|
||||
$statement->execute(array_merge([$groupId], $userIds));
|
||||
|
||||
$members = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$members[(string)$row['_mid']] = $row;
|
||||
}
|
||||
|
||||
return $members;
|
||||
}
|
||||
|
||||
function formatWebGroupMessage(
|
||||
array $row,
|
||||
string $currentUserId,
|
||||
@@ -477,6 +597,7 @@ function formatWebGroupMessage(
|
||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'senderProfile' => [
|
||||
'uid' => $from,
|
||||
'userId' => (string)($senderUser['_name'] ?? ''),
|
||||
'displayName' => $senderName,
|
||||
'rawName' => $senderRawName,
|
||||
'contactAlias' => $senderContactAlias,
|
||||
@@ -508,6 +629,379 @@ function formatWebGroupMessage(
|
||||
];
|
||||
}
|
||||
|
||||
function cleanupNativeMediaUrl(string $url): string
|
||||
{
|
||||
// WildFire's protobuf payload places the next field tag (0x40, "@")
|
||||
// and a control byte immediately after remoteMediaUrl. Keep this helper
|
||||
// defensive if it receives an unbounded match from older payloads.
|
||||
$url = preg_replace('/[\x00-\x1F\x7F].*$/s', '', $url) ?? $url;
|
||||
return rtrim($url, " \t\r\n\0\x0B\"'<>.,;)]}@");
|
||||
}
|
||||
|
||||
function nativeMediaUrlScore(string $url): int
|
||||
{
|
||||
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: $url));
|
||||
$score = 0;
|
||||
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
|
||||
$score += 30;
|
||||
}
|
||||
if (preg_match('/\.(?:mp4|mov|m4v|webm|avi|mkv|3gp)$/i', $path)) {
|
||||
$score += 30;
|
||||
}
|
||||
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
|
||||
$score += 5;
|
||||
}
|
||||
if (strpos($path, 'thumb') !== false || strpos($path, 'thumbnail') !== false) {
|
||||
$score -= 10;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
function extractNativeMediaUrlFromData($data): string
|
||||
{
|
||||
if (is_resource($data)) {
|
||||
$data = stream_get_contents($data);
|
||||
}
|
||||
if (!is_string($data) || $data === '') {
|
||||
return '';
|
||||
}
|
||||
if (!preg_match_all("~https?://[^\\s\"'<>\\x00-\\x1F\\x7F]+~i", $data, $matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$urls = [];
|
||||
foreach ($matches[0] as $url) {
|
||||
$url = cleanupNativeMediaUrl((string)$url);
|
||||
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$urls[$url] = $url;
|
||||
}
|
||||
}
|
||||
if (!$urls) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$urls = array_values($urls);
|
||||
usort($urls, static function (string $left, string $right): int {
|
||||
$scoreDelta = nativeMediaUrlScore($right) <=> nativeMediaUrlScore($left);
|
||||
return $scoreDelta !== 0 ? $scoreDelta : strlen($right) <=> strlen($left);
|
||||
});
|
||||
|
||||
return $urls[0] ?? '';
|
||||
}
|
||||
|
||||
function nativeAttachmentName(string $url, string $type): string
|
||||
{
|
||||
$path = (string)(parse_url($url, PHP_URL_PATH) ?: '');
|
||||
$name = trim(rawurldecode((string)basename($path)));
|
||||
if ($name !== '' && $name !== '.' && $name !== '/') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return $type === 'image' ? '图片' : ($type === 'video' ? '视频' : '文件');
|
||||
}
|
||||
|
||||
function nativeAttachmentMime(string $url, string $type): string
|
||||
{
|
||||
if ($type === 'video') {
|
||||
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||
if (preg_match('/\.webm$/', $path)) {
|
||||
return 'video/webm';
|
||||
}
|
||||
if (preg_match('/\.(?:mov|m4v)$/', $path)) {
|
||||
return 'video/quicktime';
|
||||
}
|
||||
return 'video/mp4';
|
||||
}
|
||||
if ($type !== 'image') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||
if (preg_match('/\.jpe?g$/', $path)) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (preg_match('/\.png$/', $path)) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (preg_match('/\.gif$/', $path)) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (preg_match('/\.webp$/', $path)) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (preg_match('/\.bmp$/', $path)) {
|
||||
return 'image/bmp';
|
||||
}
|
||||
if (preg_match('/\.avif$/', $path)) {
|
||||
return 'image/avif';
|
||||
}
|
||||
|
||||
return 'image/*';
|
||||
}
|
||||
|
||||
function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
|
||||
{
|
||||
if ($attachment !== null) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
$contentType = (int)($row['_content_type'] ?? 0);
|
||||
if (!in_array($contentType, [3, 5, 6], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = extractNativeMediaUrlFromData($row['_data'] ?? '');
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $contentType === 3 ? 'image' : ($contentType === 6 ? 'video' : 'file');
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'url' => $url,
|
||||
'name' => nativeAttachmentName($url, $type),
|
||||
'mime' => nativeAttachmentMime($url, $type),
|
||||
'size' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
function isNativePlaceholderText(string $text, int $contentType): bool
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return false;
|
||||
}
|
||||
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $contentType === 3 && ($text === '[图片]' || $text === '图片');
|
||||
}
|
||||
|
||||
function nativeDisplayText(string $searchableText, int $contentType, bool $hasAttachment, bool $isRecalled = false): string
|
||||
{
|
||||
$text = trim($searchableText);
|
||||
if (isNativePlaceholderText($text, $contentType)) {
|
||||
$text = '';
|
||||
}
|
||||
if ($text === '' && !$isRecalled) {
|
||||
return nativeMessageFallbackText($contentType, $hasAttachment);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
function nativeRedPacketFromText(string $text): ?array
|
||||
{
|
||||
$decoded = json_decode(trim($text), true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$packet = $decoded['redPackets'] ?? $decoded['redPacket'] ?? null;
|
||||
if (!is_array($packet)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string)($packet['id'] ?? ''),
|
||||
'senderId' => (string)($packet['userId'] ?? $packet['senderId'] ?? ''),
|
||||
'target' => (string)($packet['target'] ?? $decoded['target'] ?? ''),
|
||||
'blessing' => trim((string)($packet['blessing'] ?? '')) ?: '恭喜发财,大吉大利',
|
||||
'status' => (int)($packet['status'] ?? 0),
|
||||
'expireTime' => (string)($packet['expireTime'] ?? ''),
|
||||
'createDate' => (string)($packet['createDate'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
|
||||
{
|
||||
if ($hasAttachment) {
|
||||
return '';
|
||||
}
|
||||
if ($contentType === 5) {
|
||||
return '[文件]';
|
||||
}
|
||||
if ($contentType === 6) {
|
||||
return '[视频加载失败]';
|
||||
}
|
||||
if ($contentType === 90) {
|
||||
return '[红包]';
|
||||
}
|
||||
if ($contentType === 92) {
|
||||
return '以上是打招呼的内容';
|
||||
}
|
||||
if ($contentType === 93) {
|
||||
return '你们已经是好友了,可以开始聊天了。';
|
||||
}
|
||||
if ($contentType === 105) {
|
||||
return '[领取了红包]';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function nativeMessageTableForDate(DateTimeImmutable $date): string
|
||||
{
|
||||
$localDate = $date->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||
$year = (int)$localDate->format('Y');
|
||||
$month = (int)$localDate->format('n') - 1;
|
||||
|
||||
return 't_messages_' . (($year % 3) * 12 + $month);
|
||||
}
|
||||
|
||||
function recentNativeMessageTables(int $monthsBack = 3): array
|
||||
{
|
||||
$monthsBack = max(1, min($monthsBack, 12));
|
||||
$cursor = (new DateTimeImmutable('now', new DateTimeZone(IM_DB_TIMEZONE)))
|
||||
->modify('first day of this month');
|
||||
$tables = [];
|
||||
|
||||
for ($index = 0; $index < $monthsBack; $index++) {
|
||||
$tables[] = nativeMessageTableForDate($cursor);
|
||||
$cursor = $cursor->modify('-1 month');
|
||||
}
|
||||
|
||||
return array_values(array_unique($tables));
|
||||
}
|
||||
|
||||
function loadNativeGroupRowsFromGlobal(PDO $db, array $groupIds, int $limit): array
|
||||
{
|
||||
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||
if (!$groupIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = min(max($limit, 50), 1000);
|
||||
$rows = [];
|
||||
foreach (recentNativeMessageTables(3) as $table) {
|
||||
try {
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
. ' from `' . $table . '`'
|
||||
. ' where `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' order by `_mid` desc limit ' . $limit
|
||||
);
|
||||
$statement->execute($groupIds);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
usort($rows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
|
||||
$uniqueRows = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId === '' || isset($seenMessageIds[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
$uniqueRows[] = $row;
|
||||
if (count($uniqueRows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $uniqueRows;
|
||||
}
|
||||
|
||||
function formatNativeGroupMessage(
|
||||
array $row,
|
||||
string $currentUserId,
|
||||
array $group,
|
||||
?array $attachment = null,
|
||||
array $users = [],
|
||||
array $members = []
|
||||
): array {
|
||||
$groupId = (string)$row['_target'];
|
||||
$from = (string)$row['_from'];
|
||||
$senderUser = $users[$from] ?? [];
|
||||
$senderMember = $members[$from] ?? [];
|
||||
$senderAlias = trim((string)($senderMember['_alias'] ?? ''));
|
||||
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
|
||||
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
|
||||
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
|
||||
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
|
||||
?: trim((string)($senderUser['_name'] ?? ''))
|
||||
?: trim((string)($senderUser['_mobile'] ?? ''))
|
||||
?: $from;
|
||||
$senderName = $senderContactAlias
|
||||
?: $senderAlias
|
||||
?: $senderRawName;
|
||||
$senderRole = nativeGroupRoleFromType($senderMember['_type'] ?? 0);
|
||||
$messageId = (string)$row['_mid'];
|
||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||
$contentType = (int)($row['_content_type'] ?? 0);
|
||||
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
|
||||
if ($redPacket !== null) {
|
||||
$contentType = 90;
|
||||
}
|
||||
$isRecalled = $contentType === 80;
|
||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||
$messageType = $attachment
|
||||
? (string)$attachment['type']
|
||||
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
|
||||
$text = $redPacket !== null
|
||||
? '[红包]'
|
||||
: nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
|
||||
|
||||
return [
|
||||
'id' => $messageId,
|
||||
'messageUid' => $messageId,
|
||||
'conversationId' => 'group-' . $groupId,
|
||||
'from' => $from,
|
||||
'senderRole' => $senderRole,
|
||||
'senderName' => $senderName,
|
||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'senderProfile' => [
|
||||
'uid' => $from,
|
||||
'userId' => (string)($senderUser['_name'] ?? ''),
|
||||
'displayName' => $senderName,
|
||||
'rawName' => $senderRawName,
|
||||
'contactAlias' => $senderContactAlias,
|
||||
'friendAlias' => $senderFriendAlias,
|
||||
'remarkAlias' => $senderRemarkAlias,
|
||||
'groupAlias' => $senderAlias,
|
||||
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
|
||||
'mobile' => (string)($senderUser['_mobile'] ?? ''),
|
||||
'address' => (string)($senderUser['_address'] ?? ''),
|
||||
'portrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'role' => $senderRole,
|
||||
],
|
||||
'to' => $groupId,
|
||||
'target' => $groupId,
|
||||
'direction' => $direction,
|
||||
'type' => $messageType,
|
||||
'contentType' => $contentType,
|
||||
'text' => attachmentDisplayText($text, $attachment),
|
||||
'attachment' => $attachment,
|
||||
'redPacket' => $redPacket,
|
||||
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
|
||||
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
|
||||
'recalled' => $isRecalled,
|
||||
'recallStatus' => $isRecalled ? '已撤回' : '',
|
||||
'editedAt' => 0,
|
||||
'timestamp' => $timestamp,
|
||||
'activityTimestamp' => $timestamp,
|
||||
'displayTimestamp' => $timestamp,
|
||||
'conversationType' => 'group',
|
||||
'raw' => [
|
||||
'native' => true,
|
||||
'groupName' => (string)($group['_name'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
@@ -557,6 +1051,81 @@ function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $
|
||||
return $messages;
|
||||
}
|
||||
|
||||
function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||
{
|
||||
$role = nativeGroupRole($db, $userId, $groupId);
|
||||
if ($role === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupStatement = $db->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$groupStatement->execute([$groupId]);
|
||||
$group = $groupStatement->fetch();
|
||||
if (!is_array($group)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$table = imUserMessagesTable($userId);
|
||||
$scanLimit = min(max($limit * 4, $limit), 400);
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_dt` from `' . $table . '`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||
. ' order by `_mid` desc limit ' . $scanLimit
|
||||
);
|
||||
$statement->execute([$userId, $groupId]);
|
||||
$userRows = $statement->fetchAll();
|
||||
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
|
||||
$globalRows = loadNativeGroupRowsFromGlobal($db, [$groupId], $scanLimit);
|
||||
foreach ($globalRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId !== '') {
|
||||
$messageRows[$messageId] = $row;
|
||||
$userRows[] = [
|
||||
'_mid' => $messageId,
|
||||
'_dt' => (string)($row['_dt'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($userRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
|
||||
|
||||
$messages = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($userRows as $userRow) {
|
||||
$messageId = (string)$userRow['_mid'];
|
||||
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
|
||||
continue;
|
||||
}
|
||||
$messages[] = $messageRows[$messageId];
|
||||
if (count($messages) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$messages = array_reverse($messages);
|
||||
$senderIds = array_values(array_unique(array_map(static fn(array $row): string => (string)$row['_from'], $messages)));
|
||||
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||
$memberMap = fetchNativeGroupMemberMap($db, $groupId, $senderIds);
|
||||
|
||||
$readTimestamp = 0;
|
||||
$formatted = [];
|
||||
foreach ($messages as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
$message = formatNativeGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users, $memberMap);
|
||||
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
|
||||
$formatted[] = $message;
|
||||
}
|
||||
|
||||
if ($markRead) {
|
||||
markNativeGroupRead($db, $userId, $groupId, $readTimestamp);
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
@@ -619,7 +1188,7 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
||||
? (messagePreviewText([
|
||||
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
||||
'attachment' => $lastAttachment,
|
||||
]) ?: (string)$lastMessage['text'])
|
||||
]))
|
||||
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
||||
$updatedAt = $lastMessage
|
||||
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
||||
@@ -649,6 +1218,152 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
||||
return $conversations;
|
||||
}
|
||||
|
||||
function loadNativeGroupConversations(PDO $db, string $userId, int $limit): array
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||
. ' from `t_group_member` m'
|
||||
. ' join `t_group` g on g.`_gid` = m.`_gid` and g.`_deleted` = 0'
|
||||
. ' where m.`_mid` = ?'
|
||||
. ' order by greatest(g.`_dt`, m.`_dt`) desc limit ' . min(max($limit, 50), 100)
|
||||
);
|
||||
$statement->execute([$userId]);
|
||||
$groups = $statement->fetchAll();
|
||||
if (!$groups) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupIds = array_values(array_unique(array_map(static fn(array $group): string => (string)$group['_gid'], $groups)));
|
||||
$table = imUserMessagesTable($userId);
|
||||
$scanLimit = min(max($limit * 20, 200), 1000);
|
||||
$messageStatement = $db->prepare(
|
||||
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' order by `_mid` desc limit ' . $scanLimit
|
||||
);
|
||||
$messageStatement->execute(array_merge([$userId], $groupIds));
|
||||
$scanRows = $messageStatement->fetchAll();
|
||||
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
|
||||
$globalRows = loadNativeGroupRowsFromGlobal($db, $groupIds, $scanLimit);
|
||||
foreach ($globalRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId !== '') {
|
||||
$messageRows[$messageId] = $row;
|
||||
$scanRows[] = [
|
||||
'_mid' => $messageId,
|
||||
'_target' => (string)($row['_target'] ?? ''),
|
||||
'_dt' => (string)($row['_dt'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($scanRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
|
||||
$readAtByGroup = loadNativeGroupReadStateMap($db, $userId, $groupIds);
|
||||
|
||||
$latestByGroup = [];
|
||||
$unreadByGroup = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($scanRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if (isset($seenMessageIds[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
$groupId = (string)$row['_target'];
|
||||
$message = $messageRows[$messageId] ?? null;
|
||||
if ($groupId === '' || !$message || !shouldDisplayMessageForUser($message, $userId)) {
|
||||
continue;
|
||||
}
|
||||
$activityTimestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
|
||||
if (!isset($latestByGroup[$groupId]) || $activityTimestamp > (int)($latestByGroup[$groupId]['_activity_at'] ?? 0)) {
|
||||
$row['_activity_at'] = $activityTimestamp;
|
||||
$latestByGroup[$groupId] = $row;
|
||||
}
|
||||
if ((string)$message['_from'] !== $userId && !isRecallMessage($message)) {
|
||||
if ($activityTimestamp > (int)($readAtByGroup[$groupId] ?? 0)) {
|
||||
$unreadByGroup[$groupId] = (int)($unreadByGroup[$groupId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$groupsById = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupsById[(string)$group['_gid']] = $group;
|
||||
}
|
||||
|
||||
$senderIds = [];
|
||||
foreach ($latestByGroup as $row) {
|
||||
$message = $messageRows[(string)$row['_mid']] ?? null;
|
||||
if ($message) {
|
||||
$senderIds[] = (string)$message['_from'];
|
||||
}
|
||||
}
|
||||
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||
|
||||
$conversations = [];
|
||||
foreach ($groupsById as $groupId => $group) {
|
||||
$latestRow = $latestByGroup[$groupId] ?? null;
|
||||
$messageId = $latestRow ? (string)$latestRow['_mid'] : '';
|
||||
$message = $messageId !== '' && isset($messageRows[$messageId])
|
||||
? formatNativeGroupMessage(
|
||||
$messageRows[$messageId],
|
||||
$userId,
|
||||
$group,
|
||||
$attachmentMap[$messageId] ?? null,
|
||||
$users,
|
||||
fetchNativeGroupMemberMap($db, $groupId, [(string)$messageRows[$messageId]['_from']])
|
||||
)
|
||||
: null;
|
||||
$title = (string)($group['_name'] ?? '') ?: $groupId;
|
||||
$updatedAt = $message ? (int)$message['activityTimestamp'] : (int)($group['_dt'] ?? 0);
|
||||
$conversations[] = [
|
||||
'id' => 'group-' . $groupId,
|
||||
'type' => 'group',
|
||||
'target' => $groupId,
|
||||
'title' => $title,
|
||||
'subtitle' => $message
|
||||
? (messagePreviewText($message) ?: nativeMessageFallbackText((int)($message['contentType'] ?? 0), false))
|
||||
: (((int)($group['_member_count'] ?? 0) > 0) ? (int)$group['_member_count'] . ' 位成员' : '群组'),
|
||||
'unread' => (int)($unreadByGroup[$groupId] ?? 0),
|
||||
'portrait' => (string)($group['_portrait'] ?? ''),
|
||||
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||
'updatedAt' => $updatedAt,
|
||||
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||
'memberCount' => (int)($group['_member_count'] ?? 0),
|
||||
'raw' => [
|
||||
'type' => 'group',
|
||||
'target' => $groupId,
|
||||
'line' => 0,
|
||||
'native' => true,
|
||||
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $conversations;
|
||||
}
|
||||
|
||||
function mergeConversationsById(array $conversations, array $extraConversations): array
|
||||
{
|
||||
$merged = [];
|
||||
foreach (array_merge($conversations, $extraConversations) as $conversation) {
|
||||
$id = (string)($conversation['id'] ?? '');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($merged[$id])) {
|
||||
$merged[$id] = $conversation;
|
||||
continue;
|
||||
}
|
||||
if ((int)($conversation['updatedAt'] ?? 0) > (int)($merged[$id]['updatedAt'] ?? 0)) {
|
||||
$merged[$id] = array_merge($merged[$id], $conversation);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($merged);
|
||||
}
|
||||
|
||||
function isRecallMessage(array $row): bool
|
||||
{
|
||||
return (int)($row['_content_type'] ?? 0) === 80;
|
||||
@@ -675,12 +1390,21 @@ function formatMessage(
|
||||
$messageId = (string)$row['_mid'];
|
||||
$from = (string)$row['_from'];
|
||||
$contentType = (int)($row['_content_type'] ?? 0);
|
||||
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
|
||||
if ($redPacket !== null) {
|
||||
$contentType = 90;
|
||||
}
|
||||
$isRecalled = $contentType === 80;
|
||||
$text = (string)($row['_searchable_key'] ?? '');
|
||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
|
||||
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||
$messageType = $attachment
|
||||
? (string)$attachment['type']
|
||||
: (in_array($contentType, [1, 80, 90, 92, 93, 105], true) ? 'text' : 'unknown');
|
||||
$messageText = $redPacket !== null
|
||||
? '[红包]'
|
||||
: nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
|
||||
$messageText = attachmentDisplayText($messageText, $attachment);
|
||||
$editedAt = (int)($editState['editedAt'] ?? 0);
|
||||
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
||||
@@ -697,9 +1421,12 @@ function formatMessage(
|
||||
'target' => $peerId,
|
||||
'direction' => $direction,
|
||||
'type' => $messageType,
|
||||
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
|
||||
'contentType' => $attachment
|
||||
? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5))
|
||||
: $contentType,
|
||||
'text' => $messageText,
|
||||
'attachment' => $attachment,
|
||||
'redPacket' => $redPacket,
|
||||
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
|
||||
'deliveryStatus' => $deliveryStatus,
|
||||
'recalled' => $isRecalled,
|
||||
@@ -849,26 +1576,88 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
|
||||
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
|
||||
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
|
||||
'raw' => ['type' => 'single', 'target' => $peerId, 'line' => 0],
|
||||
'raw' => [
|
||||
'type' => 'single',
|
||||
'target' => $peerId,
|
||||
'line' => 0,
|
||||
'userId' => (string)($users[$peerId]['_name'] ?? $peerId),
|
||||
'userUid' => $peerId,
|
||||
'displayName' => (string)($users[$peerId]['_display_name'] ?? ''),
|
||||
'mobile' => (string)($users[$peerId]['_mobile'] ?? ''),
|
||||
'address' => (string)($users[$peerId]['_address'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||
} catch (Throwable) {
|
||||
$conversations = mergeConversationsById($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||
} catch (Throwable $error) {
|
||||
error_log('web group conversation sync failed: ' . $error->getMessage());
|
||||
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
||||
}
|
||||
try {
|
||||
$conversations = mergeConversationsById($conversations, loadNativeGroupConversations($db, $userId, $limit));
|
||||
} catch (Throwable $error) {
|
||||
error_log('native group conversation sync failed: ' . $error->getMessage());
|
||||
}
|
||||
|
||||
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
||||
|
||||
return array_slice($conversations, 0, $limit);
|
||||
}
|
||||
|
||||
function messageSyncResult(
|
||||
PDO $db,
|
||||
string $userId,
|
||||
array $messages,
|
||||
bool $includeConversations,
|
||||
int $limit,
|
||||
string $targetId = '',
|
||||
string $conversationType = 'single'
|
||||
): array {
|
||||
$result = ['messages' => $messages];
|
||||
if ($includeConversations) {
|
||||
$result['conversations'] = loadRecentConversations($db, $userId, $limit);
|
||||
}
|
||||
|
||||
if ($targetId !== '') {
|
||||
try {
|
||||
imAppDb()->exec(
|
||||
'create table if not exists `t_web_pinned_messages` ('
|
||||
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
||||
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
|
||||
. '`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`, `message_json` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$userId, $conversationType, $targetId]);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$payload = readJsonPayload();
|
||||
$userId = trim((string)($payload['userId'] ?? ''));
|
||||
$target = trim((string)($payload['target'] ?? ''));
|
||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||
|
||||
if ($userId === '') {
|
||||
@@ -881,15 +1670,50 @@ try {
|
||||
markUserDelivered($db, $userId, nowMs());
|
||||
|
||||
if ($target !== '') {
|
||||
$targetId = resolveUserId($db, $target);
|
||||
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
|
||||
respond(0, 'success', [
|
||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||
]);
|
||||
if ($conversationType === 'group') {
|
||||
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||
respond(0, 'success', messageSyncResult(
|
||||
$db,
|
||||
$userId,
|
||||
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||
$includeConversations,
|
||||
$limit, $target, 'group'
|
||||
));
|
||||
}
|
||||
if (isNativeGroupTarget($db, $target)) {
|
||||
respond(0, 'success', messageSyncResult(
|
||||
$db,
|
||||
$userId,
|
||||
loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
|
||||
$includeConversations,
|
||||
$limit, $target, 'group'
|
||||
));
|
||||
}
|
||||
respond(0, 'success', messageSyncResult(
|
||||
$db,
|
||||
$userId,
|
||||
[],
|
||||
$includeConversations,
|
||||
$limit, $target, 'group'
|
||||
));
|
||||
}
|
||||
respond(0, 'success', [
|
||||
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
||||
]);
|
||||
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||
respond(0, 'success', messageSyncResult(
|
||||
$db,
|
||||
$userId,
|
||||
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||
$includeConversations,
|
||||
$limit, $target, 'group'
|
||||
));
|
||||
}
|
||||
$targetId = resolveUserId($db, $target);
|
||||
respond(0, 'success', messageSyncResult(
|
||||
$db,
|
||||
$userId,
|
||||
loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
||||
$includeConversations,
|
||||
$limit, $targetId, 'single'
|
||||
));
|
||||
}
|
||||
|
||||
respond(0, 'success', [
|
||||
|
||||
@@ -9,6 +9,7 @@ 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_VIDEO = 3;
|
||||
const WEB_MEDIA_TYPE_FILE = 4;
|
||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
@@ -247,10 +248,29 @@ function storeMessageAttachment(
|
||||
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||
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, '缺少上传文件');
|
||||
@@ -268,9 +288,13 @@ $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);
|
||||
$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, '缺少发送者、接收者或消息内容');
|
||||
}
|
||||
|
||||
@@ -288,7 +312,66 @@ try {
|
||||
$memberStatement->execute([$target, $sender]);
|
||||
$group = $memberStatement->fetch();
|
||||
if (!$group) {
|
||||
respond(403, '你不在该群组中');
|
||||
// 手机端创建的原生群不一定存在于 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');
|
||||
@@ -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,
|
||||
'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'];
|
||||
@@ -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
+60
-2
@@ -429,7 +429,33 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
$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) {
|
||||
@@ -445,6 +471,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
return [
|
||||
'group' => [
|
||||
'groupId' => (string)$group['group_id'],
|
||||
'publicId' => (string)($group['id'] ?? ''),
|
||||
'name' => (string)$group['name'],
|
||||
'category' => (string)$group['category'],
|
||||
'announcement' => (string)($group['announcement'] ?? ''),
|
||||
@@ -462,7 +489,38 @@ 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, '你不在该群组中');
|
||||
$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(
|
||||
|
||||
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
+93
-9
@@ -9,6 +9,7 @@ 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_VIDEO = 3;
|
||||
const WEB_MEDIA_TYPE_FILE = 4;
|
||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
@@ -247,10 +248,29 @@ function storeMessageAttachment(
|
||||
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||
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, '缺少上传文件');
|
||||
@@ -268,9 +288,13 @@ $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);
|
||||
$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, '缺少发送者、接收者或消息内容');
|
||||
}
|
||||
|
||||
@@ -288,7 +312,65 @@ try {
|
||||
$memberStatement->execute([$target, $sender]);
|
||||
$group = $memberStatement->fetch();
|
||||
if (!$group) {
|
||||
respond(403, '你不在该群组中');
|
||||
// 手机端创建的原生群不一定存在于 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');
|
||||
@@ -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,
|
||||
'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'];
|
||||
@@ -345,7 +431,6 @@ try {
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
]), '原生群消息发送失败');
|
||||
} 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,
|
||||
@@ -391,7 +476,6 @@ try {
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
]), '发送失败');
|
||||
|
||||
$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
@@ -429,7 +429,33 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
$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) {
|
||||
@@ -445,6 +471,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
return [
|
||||
'group' => [
|
||||
'groupId' => (string)$group['group_id'],
|
||||
'publicId' => (string)($group['id'] ?? ''),
|
||||
'name' => (string)$group['name'],
|
||||
'category' => (string)$group['category'],
|
||||
'announcement' => (string)($group['announcement'] ?? ''),
|
||||
@@ -462,7 +489,38 @@ 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, '你不在该群组中');
|
||||
$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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -312,7 +312,65 @@ try {
|
||||
$memberStatement->execute([$target, $sender]);
|
||||
$group = $memberStatement->fetch();
|
||||
if (!$group) {
|
||||
respond(403, '你不在该群组中');
|
||||
// 手机端创建的原生群不一定存在于 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');
|
||||
@@ -373,7 +431,6 @@ try {
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
]), '原生群消息发送失败');
|
||||
} catch (Throwable $error) {
|
||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||
@@ -419,7 +476,6 @@ try {
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'userMessage' => true,
|
||||
]), '发送失败');
|
||||
|
||||
$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)
|
||||
@@ -639,6 +632,24 @@ 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 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(() => {
|
||||
@@ -799,15 +810,21 @@ async function loadGroupDetail() {
|
||||
if (!session.userId || !activeConversation.value?.target) {
|
||||
return null;
|
||||
}
|
||||
const requestedConversationId = activeConversation.value.id;
|
||||
const requestedTarget = activeConversation.value.target;
|
||||
groupBusy.value = true;
|
||||
try {
|
||||
groupDetail.value = await imClient.getGroupDetail(session.userId, activeConversation.value.target);
|
||||
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() {
|
||||
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 {
|
||||
groupMembers.value = await imClient.getGroupMembers(session.userId, activeConversation.value.target);
|
||||
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 || '群员加载失败');
|
||||
if (!silent && activeConversation.value?.id === requestedConversationId) {
|
||||
conversations.setActionError(error.message || '群员加载失败');
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
groupBusy.value = false;
|
||||
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,6 +1137,8 @@ onBeforeUnmount(() => {
|
||||
watch(
|
||||
() => activeConversation.value?.id,
|
||||
async () => {
|
||||
conversations.actionError = '';
|
||||
conversations.actionNotice = '';
|
||||
closeGroupMenu();
|
||||
closeSingleProfile();
|
||||
closeGroupPanel();
|
||||
@@ -1124,7 +1150,7 @@ watch(
|
||||
transferTargetId.value = '';
|
||||
manageSection.value = '';
|
||||
if (isGroupConversation.value) {
|
||||
await loadGroupMembers();
|
||||
await loadGroupMembers({ silent: true });
|
||||
}
|
||||
}
|
||||
, { immediate: true });
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
</div>
|
||||
<div class="composer-tools">
|
||||
<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')">
|
||||
@@ -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>
|
||||
<strong>{{ displayFileName(message) }}</strong>
|
||||
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
||||
</span>
|
||||
</a>
|
||||
<p v-if="message.text && !isRedPacketMessage(message) && !isRedPacketReceipt(message)">{{ message.text }}</p>
|
||||
<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,22 +417,114 @@ 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];
|
||||
if (!conversation?.id || !items.length) {
|
||||
|
||||
@@ -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