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);
|
$group = fetchWebGroup($appDb, $groupId);
|
||||||
if (!$group) {
|
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);
|
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||||
if (!$member) {
|
if (!$member) {
|
||||||
@@ -445,6 +471,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
return [
|
return [
|
||||||
'group' => [
|
'group' => [
|
||||||
'groupId' => (string)$group['group_id'],
|
'groupId' => (string)$group['group_id'],
|
||||||
|
'publicId' => (string)($group['id'] ?? ''),
|
||||||
'name' => (string)$group['name'],
|
'name' => (string)$group['name'],
|
||||||
'category' => (string)$group['category'],
|
'category' => (string)$group['category'],
|
||||||
'announcement' => (string)($group['announcement'] ?? ''),
|
'announcement' => (string)($group['announcement'] ?? ''),
|
||||||
@@ -462,8 +489,39 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
|
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
|
$membership->execute([$groupId, $userId]);
|
||||||
|
if (!$membership->fetchColumn()) {
|
||||||
respond(403, '你不在该群组中');
|
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(
|
$statement = $appDb->prepare(
|
||||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ require __DIR__ . '/im_web_bootstrap.php';
|
|||||||
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||||
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
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
|
function failMediaProxy(int $status, string $message): never
|
||||||
{
|
{
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
@@ -18,12 +30,13 @@ function failMediaProxy(int $status, string $message): never
|
|||||||
|
|
||||||
function mediaProxyAllowedHosts(): array
|
function mediaProxyAllowedHosts(): array
|
||||||
{
|
{
|
||||||
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||||
$hosts = array_map(
|
$hosts = array_map(
|
||||||
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||||
preg_split('/[,\s]+/', $configured) ?: []
|
preg_split('/[,\s]+/', $configured) ?: []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$hosts[] = 'zhiyanim.dfpnm.cn';
|
||||||
return array_values(array_unique(array_filter($hosts)));
|
return array_values(array_unique(array_filter($hosts)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,17 +66,20 @@ function validatedMediaProxyUrl(string $url): array
|
|||||||
|
|
||||||
function mediaProxyCacheRoot(): string
|
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)) {
|
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||||
|
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
|
||||||
|
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
|
||||||
failMediaProxy(503, '媒体缓存目录不可用');
|
failMediaProxy(503, '媒体缓存目录不可用');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $root;
|
return $root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaProxyPreferredIps(): array
|
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 = [];
|
$ips = [];
|
||||||
foreach ($configured as $ip) {
|
foreach ($configured as $ip) {
|
||||||
$ip = trim($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
|
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
||||||
{
|
{
|
||||||
$row = fetchActionMessageRow($messageUid);
|
$row = fetchActionMessageRow($messageUid);
|
||||||
@@ -223,6 +271,21 @@ if ($action === '' || $messageUid <= 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if ($action === 'pin' || $action === 'unpin') {
|
||||||
|
if ($userId === '') respond(400, '缺少操作者ID');
|
||||||
|
$targetId = trim((string)($payload['target'] ?? ''));
|
||||||
|
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
|
||||||
|
if ($targetId === '') respond(400, '缺少会话ID');
|
||||||
|
$userId = resolveActionUserId($userId);
|
||||||
|
if ($action === 'pin') {
|
||||||
|
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||||
|
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||||
|
} else {
|
||||||
|
removePinnedMessage($userId, $conversationType, $targetId);
|
||||||
|
}
|
||||||
|
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'recall') {
|
if ($action === 'recall') {
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少操作者ID');
|
respond(400, '缺少操作者ID');
|
||||||
|
|||||||
+842
-18
@@ -132,7 +132,7 @@ function fetchMessageRows(PDO $db, array $messageIds): array
|
|||||||
|
|
||||||
$messages = [];
|
$messages = [];
|
||||||
foreach ($idsByTable as $table => $ids) {
|
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) . ')';
|
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
||||||
$statement = $db->prepare($sql);
|
$statement = $db->prepare($sql);
|
||||||
$statement->execute($ids);
|
$statement->execute($ids);
|
||||||
@@ -213,6 +213,9 @@ function attachmentFallbackText(?array $attachment): string
|
|||||||
if ((string)$attachment['type'] === 'image') {
|
if ((string)$attachment['type'] === 'image') {
|
||||||
return '[图片]';
|
return '[图片]';
|
||||||
}
|
}
|
||||||
|
if ((string)$attachment['type'] === 'video') {
|
||||||
|
return '[视频]';
|
||||||
|
}
|
||||||
|
|
||||||
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
|
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
|
||||||
}
|
}
|
||||||
@@ -232,12 +235,21 @@ function messagePreviewText(?array $message): string
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
||||||
if ($attachment) {
|
$contentType = (int)($message['contentType'] ?? 0);
|
||||||
|
$messageType = (string)($message['type'] ?? '');
|
||||||
|
$placeholderIsImage = $contentType === 3 || $messageType === 'image';
|
||||||
$text = trim((string)($message['text'] ?? ''));
|
$text = trim((string)($message['text'] ?? ''));
|
||||||
|
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||||
|
$text = '';
|
||||||
|
}
|
||||||
|
if ($placeholderIsImage && ($text === '[图片]' || $text === '图片')) {
|
||||||
|
$text = '';
|
||||||
|
}
|
||||||
|
if ($attachment) {
|
||||||
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (string)($message['text'] ?? '');
|
return $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
||||||
@@ -405,6 +417,42 @@ function isWebGroupTarget(PDO $appDb, string $targetId): bool
|
|||||||
return (bool)$statement->fetchColumn();
|
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
|
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
||||||
{
|
{
|
||||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
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]);
|
$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
|
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||||
{
|
{
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
@@ -434,6 +510,50 @@ function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
|||||||
return is_string($role) && $role !== '' ? $role : '';
|
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(
|
function formatWebGroupMessage(
|
||||||
array $row,
|
array $row,
|
||||||
string $currentUserId,
|
string $currentUserId,
|
||||||
@@ -477,6 +597,7 @@ function formatWebGroupMessage(
|
|||||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
'senderProfile' => [
|
'senderProfile' => [
|
||||||
'uid' => $from,
|
'uid' => $from,
|
||||||
|
'userId' => (string)($senderUser['_name'] ?? ''),
|
||||||
'displayName' => $senderName,
|
'displayName' => $senderName,
|
||||||
'rawName' => $senderRawName,
|
'rawName' => $senderRawName,
|
||||||
'contactAlias' => $senderContactAlias,
|
'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
|
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
@@ -557,6 +1051,81 @@ function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $
|
|||||||
return $messages;
|
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
|
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
||||||
{
|
{
|
||||||
ensureWebImAppTables();
|
ensureWebImAppTables();
|
||||||
@@ -619,7 +1188,7 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
|||||||
? (messagePreviewText([
|
? (messagePreviewText([
|
||||||
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
||||||
'attachment' => $lastAttachment,
|
'attachment' => $lastAttachment,
|
||||||
]) ?: (string)$lastMessage['text'])
|
]))
|
||||||
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
||||||
$updatedAt = $lastMessage
|
$updatedAt = $lastMessage
|
||||||
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
? 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;
|
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
|
function isRecallMessage(array $row): bool
|
||||||
{
|
{
|
||||||
return (int)($row['_content_type'] ?? 0) === 80;
|
return (int)($row['_content_type'] ?? 0) === 80;
|
||||||
@@ -675,12 +1390,21 @@ function formatMessage(
|
|||||||
$messageId = (string)$row['_mid'];
|
$messageId = (string)$row['_mid'];
|
||||||
$from = (string)$row['_from'];
|
$from = (string)$row['_from'];
|
||||||
$contentType = (int)($row['_content_type'] ?? 0);
|
$contentType = (int)($row['_content_type'] ?? 0);
|
||||||
|
$redPacket = nativeRedPacketFromText((string)($row['_searchable_key'] ?? ''));
|
||||||
|
if ($redPacket !== null) {
|
||||||
|
$contentType = 90;
|
||||||
|
}
|
||||||
$isRecalled = $contentType === 80;
|
$isRecalled = $contentType === 80;
|
||||||
$text = (string)($row['_searchable_key'] ?? '');
|
$text = (string)($row['_searchable_key'] ?? '');
|
||||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||||
$messageText = $text !== '' ? $text : ($isRecalled || $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);
|
$messageText = attachmentDisplayText($messageText, $attachment);
|
||||||
$editedAt = (int)($editState['editedAt'] ?? 0);
|
$editedAt = (int)($editState['editedAt'] ?? 0);
|
||||||
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
||||||
@@ -697,9 +1421,12 @@ function formatMessage(
|
|||||||
'target' => $peerId,
|
'target' => $peerId,
|
||||||
'direction' => $direction,
|
'direction' => $direction,
|
||||||
'type' => $messageType,
|
'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,
|
'text' => $messageText,
|
||||||
'attachment' => $attachment,
|
'attachment' => $attachment,
|
||||||
|
'redPacket' => $redPacket,
|
||||||
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
|
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
|
||||||
'deliveryStatus' => $deliveryStatus,
|
'deliveryStatus' => $deliveryStatus,
|
||||||
'recalled' => $isRecalled,
|
'recalled' => $isRecalled,
|
||||||
@@ -849,26 +1576,88 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
|
|||||||
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
|
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
|
||||||
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||||
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
|
'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 {
|
try {
|
||||||
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
$conversations = mergeConversationsById($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||||
} catch (Throwable) {
|
} catch (Throwable $error) {
|
||||||
|
error_log('web group conversation sync failed: ' . $error->getMessage());
|
||||||
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
// 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']);
|
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
||||||
|
|
||||||
return array_slice($conversations, 0, $limit);
|
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();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
||||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||||
|
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
||||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||||
|
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
@@ -881,15 +1670,50 @@ try {
|
|||||||
markUserDelivered($db, $userId, nowMs());
|
markUserDelivered($db, $userId, nowMs());
|
||||||
|
|
||||||
if ($target !== '') {
|
if ($target !== '') {
|
||||||
$targetId = resolveUserId($db, $target);
|
if ($conversationType === 'group') {
|
||||||
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
|
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||||
respond(0, 'success', [
|
respond(0, 'success', messageSyncResult(
|
||||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
$db,
|
||||||
]);
|
$userId,
|
||||||
|
loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||||
|
$includeConversations,
|
||||||
|
$limit, $target, 'group'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
respond(0, 'success', [
|
if (isNativeGroupTarget($db, $target)) {
|
||||||
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
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'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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', [
|
respond(0, 'success', [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require __DIR__ . '/im_web_bootstrap.php';
|
|||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
||||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||||
|
const WEB_MEDIA_TYPE_VIDEO = 3;
|
||||||
const WEB_MEDIA_TYPE_FILE = 4;
|
const WEB_MEDIA_TYPE_FILE = 4;
|
||||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
@@ -247,10 +248,29 @@ function storeMessageAttachment(
|
|||||||
|
|
||||||
function attachmentFromRequest(string $messageType): ?array
|
function attachmentFromRequest(string $messageType): ?array
|
||||||
{
|
{
|
||||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
|
||||||
return null;
|
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;
|
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||||
if (!is_array($attachmentFile)) {
|
if (!is_array($attachmentFile)) {
|
||||||
respond(400, '缺少上传文件');
|
respond(400, '缺少上传文件');
|
||||||
@@ -268,9 +288,13 @@ $text = messageRequestValue($payload, 'text');
|
|||||||
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
||||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
$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;
|
$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, '缺少发送者、接收者或消息内容');
|
respond(400, '缺少发送者、接收者或消息内容');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,8 +312,67 @@ try {
|
|||||||
$memberStatement->execute([$target, $sender]);
|
$memberStatement->execute([$target, $sender]);
|
||||||
$group = $memberStatement->fetch();
|
$group = $memberStatement->fetch();
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
|
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||||
|
$imDb = imDb();
|
||||||
|
$nativeGroupStatement = $imDb->prepare(
|
||||||
|
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||||
|
);
|
||||||
|
$nativeGroupStatement->execute([$target]);
|
||||||
|
$nativeMemberStatement = $imDb->prepare(
|
||||||
|
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$nativeMemberStatement->execute([$target, $sender]);
|
||||||
|
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||||
|
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||||
respond(403, '你不在该群组中');
|
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');
|
$category = (string)($group['category'] ?? 'group');
|
||||||
$role = (string)($group['role'] ?? 'member');
|
$role = (string)($group['role'] ?? 'member');
|
||||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||||
@@ -319,15 +402,19 @@ try {
|
|||||||
$pushContent = $text !== ''
|
$pushContent = $text !== ''
|
||||||
? $text
|
? $text
|
||||||
: ($attachment
|
: ($attachment
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
: $text);
|
: $text);
|
||||||
$payloadBody = [
|
$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,
|
'searchableContent' => $pushContent,
|
||||||
'pushContent' => $pushContent,
|
'pushContent' => $pushContent,
|
||||||
'content' => $text,
|
'content' => $text,
|
||||||
'persistFlag' => 3,
|
'persistFlag' => 3,
|
||||||
];
|
];
|
||||||
|
if ($mentionedUserIds) {
|
||||||
|
$payloadBody['mentionedType'] = 1;
|
||||||
|
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||||
|
}
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
@@ -364,10 +451,10 @@ try {
|
|||||||
$pushContent = $text !== ''
|
$pushContent = $text !== ''
|
||||||
? $text
|
? $text
|
||||||
: ($attachment
|
: ($attachment
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
: $text);
|
: $text);
|
||||||
$payloadBody = [
|
$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,
|
'searchableContent' => $pushContent,
|
||||||
'pushContent' => $pushContent,
|
'pushContent' => $pushContent,
|
||||||
'content' => $text,
|
'content' => $text,
|
||||||
|
|||||||
+33
-22
File diff suppressed because one or more lines are too long
Vendored
+59
-1
@@ -429,7 +429,33 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
{
|
{
|
||||||
$group = fetchWebGroup($appDb, $groupId);
|
$group = fetchWebGroup($appDb, $groupId);
|
||||||
if (!$group) {
|
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);
|
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||||
if (!$member) {
|
if (!$member) {
|
||||||
@@ -445,6 +471,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
return [
|
return [
|
||||||
'group' => [
|
'group' => [
|
||||||
'groupId' => (string)$group['group_id'],
|
'groupId' => (string)$group['group_id'],
|
||||||
|
'publicId' => (string)($group['id'] ?? ''),
|
||||||
'name' => (string)$group['name'],
|
'name' => (string)$group['name'],
|
||||||
'category' => (string)$group['category'],
|
'category' => (string)$group['category'],
|
||||||
'announcement' => (string)($group['announcement'] ?? ''),
|
'announcement' => (string)($group['announcement'] ?? ''),
|
||||||
@@ -462,8 +489,39 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
|
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
|
$membership->execute([$groupId, $userId]);
|
||||||
|
if (!$membership->fetchColumn()) {
|
||||||
respond(403, '你不在该群组中');
|
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(
|
$statement = $appDb->prepare(
|
||||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||||
|
|||||||
Vendored
+71
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
|
||||||
|
|
||||||
|
function failMediaStream(int $status, string $message): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo $message;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = trim((string)($_GET['url'] ?? ''));
|
||||||
|
if ($url === '') {
|
||||||
|
failMediaStream(400, '缺少视频地址');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||||
|
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
||||||
|
failMediaStream(400, '视频地址不合法');
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = realpath(WEB_MEDIA_STREAM_ROOT);
|
||||||
|
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
|
||||||
|
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
||||||
|
failMediaStream(404, '视频文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int)filesize($fullPath);
|
||||||
|
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
|
||||||
|
if (!str_starts_with($mime, 'video/')) {
|
||||||
|
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||||
|
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = 0;
|
||||||
|
$end = max(0, $size - 1);
|
||||||
|
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
|
||||||
|
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
|
||||||
|
if ($matches[1] !== '') $start = (int)$matches[1];
|
||||||
|
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
|
||||||
|
if ($start < 0 || $start > $end || $start >= $size) {
|
||||||
|
header('Content-Range: bytes */' . $size);
|
||||||
|
failMediaStream(416, '请求范围无效');
|
||||||
|
}
|
||||||
|
http_response_code(206);
|
||||||
|
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = $end - $start + 1;
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . $length);
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
|
header('Content-Disposition: inline');
|
||||||
|
header('Cache-Control: public, max-age=86400');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
$stream = fopen($fullPath, 'rb');
|
||||||
|
if ($stream === false) failMediaStream(500, '视频文件无法读取');
|
||||||
|
fseek($stream, $start);
|
||||||
|
$remaining = $length;
|
||||||
|
while ($remaining > 0 && !feof($stream)) {
|
||||||
|
$chunk = fread($stream, min(1024 * 1024, $remaining));
|
||||||
|
if ($chunk === false || $chunk === '') break;
|
||||||
|
echo $chunk;
|
||||||
|
$remaining -= strlen($chunk);
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
fclose($stream);
|
||||||
Vendored
+22
-6
@@ -97,26 +97,41 @@ function ensureMessageEditsTable(): void
|
|||||||
|
|
||||||
function ensurePinnedMessagesTable(): void
|
function ensurePinnedMessagesTable(): void
|
||||||
{
|
{
|
||||||
imAppDb()->exec(
|
$db = imAppDb();
|
||||||
|
$db->exec(
|
||||||
'create table if not exists `t_web_pinned_messages` ('
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
. '`user_id` varchar(64) not null,'
|
. '`user_id` varchar(64) not null,'
|
||||||
. '`conversation_type` varchar(16) not null,'
|
. '`conversation_type` varchar(16) not null,'
|
||||||
. '`target_id` varchar(128) not null,'
|
. '`target_id` varchar(128) not null,'
|
||||||
. '`message_uid` varchar(32) not null,'
|
. '`message_uid` varchar(32) not null,'
|
||||||
|
. '`message_json` text null,'
|
||||||
. '`updated_at_ms` bigint not null,'
|
. '`updated_at_ms` bigint not null,'
|
||||||
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') 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();
|
ensurePinnedMessagesTable();
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `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`), `updated_at_ms` = values(`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
|
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
||||||
@@ -263,7 +278,8 @@ try {
|
|||||||
if ($targetId === '') respond(400, '缺少会话ID');
|
if ($targetId === '') respond(400, '缺少会话ID');
|
||||||
$userId = resolveActionUserId($userId);
|
$userId = resolveActionUserId($userId);
|
||||||
if ($action === 'pin') {
|
if ($action === 'pin') {
|
||||||
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
|
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||||
|
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||||
} else {
|
} else {
|
||||||
removePinnedMessage($userId, $conversationType, $targetId);
|
removePinnedMessage($userId, $conversationType, $targetId);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+16
-4
@@ -597,6 +597,7 @@ function formatWebGroupMessage(
|
|||||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
'senderProfile' => [
|
'senderProfile' => [
|
||||||
'uid' => $from,
|
'uid' => $from,
|
||||||
|
'userId' => (string)($senderUser['_name'] ?? ''),
|
||||||
'displayName' => $senderName,
|
'displayName' => $senderName,
|
||||||
'rawName' => $senderRawName,
|
'rawName' => $senderRawName,
|
||||||
'contactAlias' => $senderContactAlias,
|
'contactAlias' => $senderContactAlias,
|
||||||
@@ -964,6 +965,7 @@ function formatNativeGroupMessage(
|
|||||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
'senderProfile' => [
|
'senderProfile' => [
|
||||||
'uid' => $from,
|
'uid' => $from,
|
||||||
|
'userId' => (string)($senderUser['_name'] ?? ''),
|
||||||
'displayName' => $senderName,
|
'displayName' => $senderName,
|
||||||
'rawName' => $senderRawName,
|
'rawName' => $senderRawName,
|
||||||
'contactAlias' => $senderContactAlias,
|
'contactAlias' => $senderContactAlias,
|
||||||
@@ -1064,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
|
|||||||
}
|
}
|
||||||
|
|
||||||
$table = imUserMessagesTable($userId);
|
$table = imUserMessagesTable($userId);
|
||||||
$scanLimit = min(max($limit * 4, $limit), 400);
|
$scanLimit = min(max($limit * 4, $limit), 5000);
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_mid`, `_dt` from `' . $table . '`'
|
'select `_mid`, `_dt` from `' . $table . '`'
|
||||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
. ' 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` ('
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
||||||
. '`target_id` varchar(128) not null, `message_uid` varchar(32) 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`)'
|
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') 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(
|
$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]);
|
$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) {
|
} catch (Throwable) {
|
||||||
$result['pinnedMessageUid'] = '';
|
$result['pinnedMessageUid'] = '';
|
||||||
|
$result['pinnedMessage'] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1643,7 +1655,7 @@ function messageSyncResult(
|
|||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
|
||||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||||
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
||||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||||
|
|||||||
Vendored
+92
-8
@@ -9,6 +9,7 @@ require __DIR__ . '/im_web_bootstrap.php';
|
|||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
||||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||||
|
const WEB_MEDIA_TYPE_VIDEO = 3;
|
||||||
const WEB_MEDIA_TYPE_FILE = 4;
|
const WEB_MEDIA_TYPE_FILE = 4;
|
||||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
@@ -247,10 +248,29 @@ function storeMessageAttachment(
|
|||||||
|
|
||||||
function attachmentFromRequest(string $messageType): ?array
|
function attachmentFromRequest(string $messageType): ?array
|
||||||
{
|
{
|
||||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
|
||||||
return null;
|
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;
|
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||||
if (!is_array($attachmentFile)) {
|
if (!is_array($attachmentFile)) {
|
||||||
respond(400, '缺少上传文件');
|
respond(400, '缺少上传文件');
|
||||||
@@ -268,9 +288,13 @@ $text = messageRequestValue($payload, 'text');
|
|||||||
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
||||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
$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;
|
$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, '缺少发送者、接收者或消息内容');
|
respond(400, '缺少发送者、接收者或消息内容');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,8 +312,66 @@ try {
|
|||||||
$memberStatement->execute([$target, $sender]);
|
$memberStatement->execute([$target, $sender]);
|
||||||
$group = $memberStatement->fetch();
|
$group = $memberStatement->fetch();
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
|
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||||
|
$imDb = imDb();
|
||||||
|
$nativeGroupStatement = $imDb->prepare(
|
||||||
|
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||||
|
);
|
||||||
|
$nativeGroupStatement->execute([$target]);
|
||||||
|
$nativeMemberStatement = $imDb->prepare(
|
||||||
|
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$nativeMemberStatement->execute([$target, $sender]);
|
||||||
|
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||||
|
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||||
respond(403, '你不在该群组中');
|
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');
|
$category = (string)($group['category'] ?? 'group');
|
||||||
$role = (string)($group['role'] ?? 'member');
|
$role = (string)($group['role'] ?? 'member');
|
||||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||||
@@ -319,15 +401,19 @@ try {
|
|||||||
$pushContent = $text !== ''
|
$pushContent = $text !== ''
|
||||||
? $text
|
? $text
|
||||||
: ($attachment
|
: ($attachment
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
: $text);
|
: $text);
|
||||||
$payloadBody = [
|
$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,
|
'searchableContent' => $pushContent,
|
||||||
'pushContent' => $pushContent,
|
'pushContent' => $pushContent,
|
||||||
'content' => $text,
|
'content' => $text,
|
||||||
'persistFlag' => 3,
|
'persistFlag' => 3,
|
||||||
];
|
];
|
||||||
|
if ($mentionedUserIds) {
|
||||||
|
$payloadBody['mentionedType'] = 1;
|
||||||
|
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||||
|
}
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
@@ -345,7 +431,6 @@ try {
|
|||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => $payloadBody,
|
||||||
'userMessage' => true,
|
|
||||||
]), '原生群消息发送失败');
|
]), '原生群消息发送失败');
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||||
@@ -364,10 +449,10 @@ try {
|
|||||||
$pushContent = $text !== ''
|
$pushContent = $text !== ''
|
||||||
? $text
|
? $text
|
||||||
: ($attachment
|
: ($attachment
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
: $text);
|
: $text);
|
||||||
$payloadBody = [
|
$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,
|
'searchableContent' => $pushContent,
|
||||||
'pushContent' => $pushContent,
|
'pushContent' => $pushContent,
|
||||||
'content' => $text,
|
'content' => $text,
|
||||||
@@ -391,7 +476,6 @@ try {
|
|||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => $payloadBody,
|
||||||
'userMessage' => true,
|
|
||||||
]), '发送失败');
|
]), '发送失败');
|
||||||
|
|
||||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
|||||||
@@ -2455,6 +2455,53 @@ button {
|
|||||||
background: #111;
|
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 {
|
.red-packet-card {
|
||||||
width: min(320px, 68vw);
|
width: min(320px, 68vw);
|
||||||
margin: -10px -12px -8px;
|
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
+1000
-473
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);
|
$group = fetchWebGroup($appDb, $groupId);
|
||||||
if (!$group) {
|
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);
|
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||||
if (!$member) {
|
if (!$member) {
|
||||||
@@ -445,6 +471,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
return [
|
return [
|
||||||
'group' => [
|
'group' => [
|
||||||
'groupId' => (string)$group['group_id'],
|
'groupId' => (string)$group['group_id'],
|
||||||
|
'publicId' => (string)($group['id'] ?? ''),
|
||||||
'name' => (string)$group['name'],
|
'name' => (string)$group['name'],
|
||||||
'category' => (string)$group['category'],
|
'category' => (string)$group['category'],
|
||||||
'announcement' => (string)($group['announcement'] ?? ''),
|
'announcement' => (string)($group['announcement'] ?? ''),
|
||||||
@@ -462,8 +489,39 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
|
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
|
$membership->execute([$groupId, $userId]);
|
||||||
|
if (!$membership->fetchColumn()) {
|
||||||
respond(403, '你不在该群组中');
|
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(
|
$statement = $appDb->prepare(
|
||||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||||
|
|||||||
@@ -97,26 +97,41 @@ function ensureMessageEditsTable(): void
|
|||||||
|
|
||||||
function ensurePinnedMessagesTable(): void
|
function ensurePinnedMessagesTable(): void
|
||||||
{
|
{
|
||||||
imAppDb()->exec(
|
$db = imAppDb();
|
||||||
|
$db->exec(
|
||||||
'create table if not exists `t_web_pinned_messages` ('
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
. '`user_id` varchar(64) not null,'
|
. '`user_id` varchar(64) not null,'
|
||||||
. '`conversation_type` varchar(16) not null,'
|
. '`conversation_type` varchar(16) not null,'
|
||||||
. '`target_id` varchar(128) not null,'
|
. '`target_id` varchar(128) not null,'
|
||||||
. '`message_uid` varchar(32) not null,'
|
. '`message_uid` varchar(32) not null,'
|
||||||
|
. '`message_json` text null,'
|
||||||
. '`updated_at_ms` bigint not null,'
|
. '`updated_at_ms` bigint not null,'
|
||||||
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') 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();
|
ensurePinnedMessagesTable();
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `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`), `updated_at_ms` = values(`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
|
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
||||||
@@ -263,7 +278,8 @@ try {
|
|||||||
if ($targetId === '') respond(400, '缺少会话ID');
|
if ($targetId === '') respond(400, '缺少会话ID');
|
||||||
$userId = resolveActionUserId($userId);
|
$userId = resolveActionUserId($userId);
|
||||||
if ($action === 'pin') {
|
if ($action === 'pin') {
|
||||||
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
|
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||||
|
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||||
} else {
|
} else {
|
||||||
removePinnedMessage($userId, $conversationType, $targetId);
|
removePinnedMessage($userId, $conversationType, $targetId);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -1066,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
|
|||||||
}
|
}
|
||||||
|
|
||||||
$table = imUserMessagesTable($userId);
|
$table = imUserMessagesTable($userId);
|
||||||
$scanLimit = min(max($limit * 4, $limit), 400);
|
$scanLimit = min(max($limit * 4, $limit), 5000);
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_mid`, `_dt` from `' . $table . '`'
|
'select `_mid`, `_dt` from `' . $table . '`'
|
||||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
. ' 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` ('
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
||||||
. '`target_id` varchar(128) not null, `message_uid` varchar(32) 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`)'
|
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') 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(
|
$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]);
|
$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) {
|
} catch (Throwable) {
|
||||||
$result['pinnedMessageUid'] = '';
|
$result['pinnedMessageUid'] = '';
|
||||||
|
$result['pinnedMessage'] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1645,7 +1655,7 @@ function messageSyncResult(
|
|||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
|
||||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||||
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
||||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||||
|
|||||||
@@ -312,8 +312,66 @@ try {
|
|||||||
$memberStatement->execute([$target, $sender]);
|
$memberStatement->execute([$target, $sender]);
|
||||||
$group = $memberStatement->fetch();
|
$group = $memberStatement->fetch();
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
|
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||||
|
$imDb = imDb();
|
||||||
|
$nativeGroupStatement = $imDb->prepare(
|
||||||
|
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||||
|
);
|
||||||
|
$nativeGroupStatement->execute([$target]);
|
||||||
|
$nativeMemberStatement = $imDb->prepare(
|
||||||
|
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$nativeMemberStatement->execute([$target, $sender]);
|
||||||
|
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||||
|
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||||
respond(403, '你不在该群组中');
|
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');
|
$category = (string)($group['category'] ?? 'group');
|
||||||
$role = (string)($group['role'] ?? 'member');
|
$role = (string)($group['role'] ?? 'member');
|
||||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||||
@@ -373,7 +431,6 @@ try {
|
|||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => $payloadBody,
|
||||||
'userMessage' => true,
|
|
||||||
]), '原生群消息发送失败');
|
]), '原生群消息发送失败');
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||||
@@ -419,7 +476,6 @@ try {
|
|||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => $payloadBody,
|
||||||
'userMessage' => true,
|
|
||||||
]), '发送失败');
|
]), '发送失败');
|
||||||
|
|
||||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
<MessageComposer
|
<MessageComposer
|
||||||
:disabled="!activeConversation || !session.isAuthenticated || !conversations.canSendActiveConversation"
|
:disabled="!activeConversation || !session.isAuthenticated || !conversations.canSendActiveConversation"
|
||||||
:quote-message="conversations.quotedMessage"
|
:quote-message="conversations.quotedMessage"
|
||||||
:mention-users="isGroupConversation ? groupMembers : []"
|
:mention-users="isGroupConversation ? mentionCandidates : []"
|
||||||
@cancel-quote="conversations.cancelQuote"
|
@cancel-quote="conversations.cancelQuote"
|
||||||
@send="conversations.sendText"
|
@send="conversations.sendText"
|
||||||
@send-attachment="conversations.sendAttachment"
|
@send-attachment="conversations.sendAttachment"
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
<dt>群名</dt>
|
<dt>群名</dt>
|
||||||
<dd>{{ groupDetail?.name || activeConversation?.title || '-' }}</dd>
|
<dd>{{ groupDetail?.name || activeConversation?.title || '-' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div v-if="groupDisplayId">
|
||||||
<dt>群ID</dt>
|
<dt>群ID</dt>
|
||||||
<dd>{{ groupDisplayId }}</dd>
|
<dd>{{ groupDisplayId }}</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -619,14 +619,7 @@ const singleProfile = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const groupDisplayId = computed(() => {
|
const groupDisplayId = computed(() => {
|
||||||
return String(
|
return String(groupDetail.value?.publicId || '');
|
||||||
groupDetail.value?.groupId
|
|
||||||
|| activeConversation.value?.raw?.groupId
|
|
||||||
|| activeConversation.value?.raw?.target
|
|
||||||
|| activeConversation.value?.target
|
|
||||||
|| activeConversation.value?.id?.replace(/^group-/, '')
|
|
||||||
|| '-'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
const isGroupConversation = computed(() => {
|
const isGroupConversation = computed(() => {
|
||||||
return ['group', 'channel'].includes(activeConversation.value?.type)
|
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 canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
|
||||||
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||||
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
const 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 messageDetail = computed(() => conversations.messageDetail || null);
|
||||||
const messageDetailGroup = computed(() => messageDetail.value?.group || null);
|
const messageDetailGroup = computed(() => messageDetail.value?.group || null);
|
||||||
const isMessageDetailGroup = computed(() => {
|
const isMessageDetailGroup = computed(() => {
|
||||||
@@ -799,15 +810,21 @@ async function loadGroupDetail() {
|
|||||||
if (!session.userId || !activeConversation.value?.target) {
|
if (!session.userId || !activeConversation.value?.target) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const requestedConversationId = activeConversation.value.id;
|
||||||
|
const requestedTarget = activeConversation.value.target;
|
||||||
groupBusy.value = true;
|
groupBusy.value = true;
|
||||||
try {
|
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;
|
return groupDetail.value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (activeConversation.value?.id === requestedConversationId) {
|
||||||
conversations.setActionError(error.message || '群资料加载失败');
|
conversations.setActionError(error.message || '群资料加载失败');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
groupBusy.value = false;
|
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,19 +843,26 @@ async function openGroupMembers() {
|
|||||||
await loadGroupMembers();
|
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;
|
groupBusy.value = true;
|
||||||
try {
|
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
|
selectedAdminIds.value = groupMembers.value
|
||||||
.filter(member => member.role === 'admin')
|
.filter(member => member.role === 'admin')
|
||||||
.map(member => member.uid);
|
.map(member => member.uid);
|
||||||
return groupMembers.value;
|
return groupMembers.value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!silent && activeConversation.value?.id === requestedConversationId) {
|
||||||
conversations.setActionError(error.message || '群员加载失败');
|
conversations.setActionError(error.message || '群员加载失败');
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
groupBusy.value = false;
|
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,6 +1137,8 @@ onBeforeUnmount(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => activeConversation.value?.id,
|
() => activeConversation.value?.id,
|
||||||
async () => {
|
async () => {
|
||||||
|
conversations.actionError = '';
|
||||||
|
conversations.actionNotice = '';
|
||||||
closeGroupMenu();
|
closeGroupMenu();
|
||||||
closeSingleProfile();
|
closeSingleProfile();
|
||||||
closeGroupPanel();
|
closeGroupPanel();
|
||||||
@@ -1124,7 +1150,7 @@ watch(
|
|||||||
transferTargetId.value = '';
|
transferTargetId.value = '';
|
||||||
manageSection.value = '';
|
manageSection.value = '';
|
||||||
if (isGroupConversation.value) {
|
if (isGroupConversation.value) {
|
||||||
await loadGroupMembers();
|
await loadGroupMembers({ silent: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
, { immediate: true });
|
, { immediate: true });
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="composer-tools">
|
<div class="composer-tools">
|
||||||
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
||||||
|
<button type="button" title="艾特群成员" :disabled="disabled || !mentionUsers.length" @click="openMentionPicker">@</button>
|
||||||
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
|
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
|
||||||
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
|
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
|
||||||
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
||||||
@@ -123,12 +124,27 @@ function mentionName(member) {
|
|||||||
function handleMentionInput(event) {
|
function handleMentionInput(event) {
|
||||||
const cursor = event?.target?.selectionStart ?? text.value.length;
|
const cursor = event?.target?.selectionStart ?? text.value.length;
|
||||||
const beforeCursor = text.value.slice(0, cursor);
|
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);
|
mentionOpen.value = Boolean(match && props.mentionUsers.length);
|
||||||
mentionQuery.value = match?.[1] || '';
|
mentionQuery.value = match?.[1] || '';
|
||||||
mentionStart.value = match ? cursor - match[1].length - 1 : -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) {
|
function selectMention(member) {
|
||||||
const element = textareaEl.value;
|
const element = textareaEl.value;
|
||||||
const cursor = element?.selectionStart ?? text.value.length;
|
const cursor = element?.selectionStart ?? text.value.length;
|
||||||
|
|||||||
@@ -67,12 +67,12 @@
|
|||||||
<video
|
<video
|
||||||
v-else-if="isVideoMessage(message)"
|
v-else-if="isVideoMessage(message)"
|
||||||
class="message-video"
|
class="message-video"
|
||||||
|
:src="videoDisplayUrl(message.attachment.url)"
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
preload="metadata"
|
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>
|
</video>
|
||||||
<a
|
<a
|
||||||
@@ -85,11 +85,11 @@
|
|||||||
>
|
>
|
||||||
<span class="message-file-icon">▤</span>
|
<span class="message-file-icon">▤</span>
|
||||||
<span class="message-file-main">
|
<span class="message-file-main">
|
||||||
<strong>{{ message.attachment.name || '文件' }}</strong>
|
<strong>{{ displayFileName(message) }}</strong>
|
||||||
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</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>
|
<time>
|
||||||
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
|
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
|
||||||
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
|
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useContactStore } from '@/stores/contacts';
|
import { useContactStore } from '@/stores/contacts';
|
||||||
import { useImageProxyFallback, videoDisplayUrl } from '@/services/mediaProxy';
|
import { useImageProxyFallback, useVideoOriginalFallback, videoDisplayUrl } from '@/services/mediaProxy';
|
||||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -463,6 +463,12 @@ function isFileMessage(message) {
|
|||||||
return message?.type === 'file' && message?.attachment?.url;
|
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) {
|
function isVideoMessage(message) {
|
||||||
return (message?.type === 'video' || Number(message?.contentType || 0) === 6) && message?.attachment?.url;
|
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') {
|
if (message.status === 'sent') {
|
||||||
return !hasRemoteMatch(message) && now - Number(message.timestamp || 0) < 30000;
|
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) => {
|
return [...visibleRemoteMessages, ...pendingLocalMessages].sort((a, b) => {
|
||||||
@@ -395,7 +401,9 @@ export class HttpImClient {
|
|||||||
displayTimestamp: message.displayTimestamp || message.timestamp,
|
displayTimestamp: message.displayTimestamp || message.timestamp,
|
||||||
conversationId: conversation.id
|
conversationId: conversation.id
|
||||||
}));
|
}));
|
||||||
|
messages.hasPinnedMessageState = Object.prototype.hasOwnProperty.call(response.data?.result || {}, 'pinnedMessageUid');
|
||||||
messages.pinnedMessageUid = String(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)) {
|
if (options.includeConversations && Array.isArray(response.data?.result?.conversations)) {
|
||||||
const remoteConversations = response.data.result.conversations.map(remoteConversation => ({
|
const remoteConversations = response.data.result.conversations.map(remoteConversation => ({
|
||||||
...remoteConversation,
|
...remoteConversation,
|
||||||
@@ -409,22 +417,114 @@ export class HttpImClient {
|
|||||||
const messagesByConversation = readLocalMessages(userId);
|
const messagesByConversation = readLocalMessages(userId);
|
||||||
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
||||||
messagesByConversation[conversation.id].pinnedMessageUid = messages.pinnedMessageUid;
|
messagesByConversation[conversation.id].pinnedMessageUid = messages.pinnedMessageUid;
|
||||||
|
messagesByConversation[conversation.id].pinnedMessage = messages.pinnedMessage;
|
||||||
|
messagesByConversation[conversation.id].hasPinnedMessageState = messages.hasPinnedMessageState;
|
||||||
writeLocalMessages(messagesByConversation, userId);
|
writeLocalMessages(messagesByConversation, userId);
|
||||||
return messagesByConversation[conversation.id];
|
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 action = messageUid ? 'pin' : 'unpin';
|
||||||
const response = await axios.post(appConfig.messageActionApi, {
|
const response = await axios.post(appConfig.messageActionApi, {
|
||||||
action,
|
action,
|
||||||
userId: this.currentUser?.userId || '',
|
userId: this.currentUser?.userId || '',
|
||||||
messageUid: messageUid || '1',
|
messageUid: messageUid || '1',
|
||||||
target: conversation.target || conversation.raw?.target || '',
|
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 });
|
}, { withCredentials: false });
|
||||||
if (response.data?.code !== 0) throw new Error(response.data?.message || '置顶失败');
|
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) {
|
hideMessages(conversation, messages) {
|
||||||
const items = Array.isArray(messages) ? messages : [messages];
|
const items = Array.isArray(messages) ? messages : [messages];
|
||||||
if (!conversation?.id || !items.length) {
|
if (!conversation?.id || !items.length) {
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ export function videoDisplayUrl(value) {
|
|||||||
return streamUrl.toString();
|
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) {
|
export function useImageProxyFallback(event, value) {
|
||||||
const image = event?.currentTarget;
|
const image = event?.currentTarget;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|||||||
+96
-11
@@ -63,6 +63,39 @@ function writePinnedMessages(messages) {
|
|||||||
localStorage.setItem(scopedStorageKey(LOCAL_PINNED_MESSAGES_KEY), JSON.stringify(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) {
|
function sortConversations(conversations) {
|
||||||
return [...conversations].sort((left, right) => {
|
return [...conversations].sort((left, right) => {
|
||||||
const leftPinned = Number(left.pinnedAt || left.top || 0);
|
const leftPinned = Number(left.pinnedAt || left.top || 0);
|
||||||
@@ -318,6 +351,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
selectedMessageIds: [],
|
selectedMessageIds: [],
|
||||||
favoriteMessages: [],
|
favoriteMessages: [],
|
||||||
pinnedMessagesByConversation: {},
|
pinnedMessagesByConversation: {},
|
||||||
|
groupPinnedSyncAt: {},
|
||||||
hiddenConversationIds: [],
|
hiddenConversationIds: [],
|
||||||
activeFavoriteKey: '',
|
activeFavoriteKey: '',
|
||||||
actionNotice: '',
|
actionNotice: '',
|
||||||
@@ -383,6 +417,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
this.conversations = [];
|
this.conversations = [];
|
||||||
this.favoriteMessages = readFavoriteMessages();
|
this.favoriteMessages = readFavoriteMessages();
|
||||||
this.pinnedMessagesByConversation = readPinnedMessages();
|
this.pinnedMessagesByConversation = readPinnedMessages();
|
||||||
|
this.groupPinnedSyncAt = {};
|
||||||
this.hiddenConversationIds = readHiddenConversationIds();
|
this.hiddenConversationIds = readHiddenConversationIds();
|
||||||
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
|
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
|
||||||
this.activeId = '';
|
this.activeId = '';
|
||||||
@@ -851,13 +886,40 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const syncedConversationId = conversation.id;
|
const syncedConversationId = conversation.id;
|
||||||
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
|
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
|
||||||
try {
|
try {
|
||||||
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
|
const messages = await imClient.fetchRemoteMessages(conversation, 1000, {
|
||||||
markRead,
|
markRead,
|
||||||
includeConversations: Boolean(options.includeConversations)
|
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 || '');
|
const remotePinnedKey = String(messages.pinnedMessageUid || '');
|
||||||
if (remotePinnedKey) {
|
if (!isGroupConversation && remotePinnedKey) {
|
||||||
const pinnedMessage = messages.find(item => messageStableKey(item) === remotePinnedKey);
|
const pinnedMessage = messages.find(item => messageStableKey(item) === remotePinnedKey)
|
||||||
|
|| messages.pinnedMessage
|
||||||
|
|| (this.pinnedMessagesByConversation[syncedConversationId]?.key === remotePinnedKey
|
||||||
|
? this.pinnedMessagesByConversation[syncedConversationId]
|
||||||
|
: null);
|
||||||
this.pinnedMessagesByConversation = {
|
this.pinnedMessagesByConversation = {
|
||||||
...this.pinnedMessagesByConversation,
|
...this.pinnedMessagesByConversation,
|
||||||
[syncedConversationId]: pinnedMessage
|
[syncedConversationId]: pinnedMessage
|
||||||
@@ -1138,16 +1200,16 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const current = this.pinnedMessagesByConversation[conversationId];
|
const current = this.pinnedMessagesByConversation[conversationId];
|
||||||
const next = { ...this.pinnedMessagesByConversation };
|
const next = { ...this.pinnedMessagesByConversation };
|
||||||
if (current && current.key === key) {
|
if (current && current.key === key) {
|
||||||
await imClient.setPinnedMessage(this.activeConversation, '').catch(error => this.setActionError(error.message || '取消置顶失败'));
|
|
||||||
delete next[conversationId];
|
delete next[conversationId];
|
||||||
|
this.pinnedMessagesByConversation = next;
|
||||||
|
writePinnedMessages(next);
|
||||||
this.setActionNotice('已取消置顶');
|
this.setActionNotice('已取消置顶');
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
await imClient.setPinnedMessage(this.activeConversation, key);
|
await imClient.setPinnedMessage(this.activeConversation, '', current);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setActionError(error.message || '置顶失败');
|
this.setActionError(`已在本地取消,服务器同步失败:${error.message || '取消置顶失败'}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
next[conversationId] = {
|
next[conversationId] = {
|
||||||
key,
|
key,
|
||||||
id: key,
|
id: key,
|
||||||
@@ -1157,18 +1219,41 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
senderName: message.senderName || message.from || '',
|
senderName: message.senderName || message.from || '',
|
||||||
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
|
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
|
||||||
};
|
};
|
||||||
this.setActionNotice('消息已置顶');
|
|
||||||
}
|
|
||||||
this.pinnedMessagesByConversation = next;
|
this.pinnedMessagesByConversation = next;
|
||||||
writePinnedMessages(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 || '服务器同步失败'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async clearPinnedMessage() {
|
async clearPinnedMessage() {
|
||||||
await imClient.setPinnedMessage(this.activeConversation, '').catch(error => this.setActionError(error.message || '取消置顶失败'));
|
const current = this.pinnedMessagesByConversation[this.activeId] || null;
|
||||||
const next = { ...this.pinnedMessagesByConversation };
|
const next = { ...this.pinnedMessagesByConversation };
|
||||||
delete next[this.activeId];
|
delete next[this.activeId];
|
||||||
this.pinnedMessagesByConversation = next;
|
this.pinnedMessagesByConversation = next;
|
||||||
writePinnedMessages(next);
|
writePinnedMessages(next);
|
||||||
this.setActionNotice('已取消置顶');
|
this.setActionNotice('已取消置顶');
|
||||||
|
try {
|
||||||
|
await imClient.setPinnedMessage(this.activeConversation, '', current);
|
||||||
|
} catch (error) {
|
||||||
|
this.setActionError(`已在本地取消,服务器同步失败:${error.message || '取消置顶失败'}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setActiveFavorite(key) {
|
setActiveFavorite(key) {
|
||||||
this.activeFavoriteKey = String(key || '');
|
this.activeFavoriteKey = String(key || '');
|
||||||
|
|||||||
Reference in New Issue
Block a user