feat: improve realtime messaging and media handling

This commit is contained in:
winchen
2026-07-28 19:43:14 +08:00
parent 051f60aaf0
commit 6f18980a64
31 changed files with 25265 additions and 21814 deletions
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE=${JC_WEB_IM_UPDATE_CONFIG:-/etc/jc-web-im-update.conf}
STATE_DIR=/var/lib/jc-web-im-update
LOCK_FILE=/run/lock/jc-web-im-update.lock
if [[ ! -r "$CONFIG_FILE" ]]; then
echo "Missing configuration: $CONFIG_FILE" >&2
exit 1
fi
# shellcheck source=/dev/null
source "$CONFIG_FILE"
: "${REPO_DIR:?REPO_DIR is required}"
: "${WEB_ROOT:?WEB_ROOT is required}"
: "${BRANCH:=main}"
case "$REPO_DIR" in
/opt/jc-web-im-release|/opt/jc-web-im-release-*) ;;
*) echo "Unsafe REPO_DIR: $REPO_DIR" >&2; exit 1 ;;
esac
case "$WEB_ROOT" in
/var/www/jc-web-im|/var/www/jc-web-im-*) ;;
*) echo "Unsafe WEB_ROOT: $WEB_ROOT" >&2; exit 1 ;;
esac
mkdir -p "$STATE_DIR"
exec 9>"$LOCK_FILE"
flock -n 9 || {
echo "Another update is already running"
exit 0
}
git -C "$REPO_DIR" fetch --quiet origin "$BRANCH"
TARGET_COMMIT=$(git -C "$REPO_DIR" rev-parse "origin/$BRANCH")
CURRENT_COMMIT=$(cat "$STATE_DIR/last_deployed" 2>/dev/null || true)
if [[ "$TARGET_COMMIT" == "$CURRENT_COMMIT" ]]; then
echo "Already current: $TARGET_COMMIT"
exit 0
fi
git -C "$REPO_DIR" reset --hard --quiet "$TARGET_COMMIT"
DIST_DIR="$REPO_DIR/dist"
[[ -f "$DIST_DIR/index.html" ]] || {
echo "Missing dist/index.html at $TARGET_COMMIT" >&2
exit 1
}
while IFS= read -r asset; do
[[ -f "$DIST_DIR/$asset" ]] || {
echo "Missing referenced asset: $asset" >&2
exit 1
}
done < <(sed -nE 's/.*(css\/[^" ]+|js\/[^" ]+).*/\1/p' "$DIST_DIR/index.html")
find "$DIST_DIR/api" -type f -name '*.php' -print0 |
xargs -0 -r -n1 php -l >/dev/null
BACKUP_DIR="/var/backups/jc-web-im/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
[[ -f "$WEB_ROOT/index.html" ]] && cp -a "$WEB_ROOT/index.html" "$BACKUP_DIR/"
[[ -f "$WEB_ROOT/config/runtime-config.js" ]] && {
mkdir -p "$BACKUP_DIR/config"
cp -a "$WEB_ROOT/config/runtime-config.js" "$BACKUP_DIR/config/"
}
mkdir -p "$WEB_ROOT"
rsync -a --exclude='/config/runtime-config.js' "$DIST_DIR/" "$WEB_ROOT/"
chown -R www-data:www-data "$WEB_ROOT"
printf '%s\n' "$TARGET_COMMIT" >"$STATE_DIR/last_deployed"
echo "Deployed: $TARGET_COMMIT -> $WEB_ROOT"
+6 -10
View File
@@ -34,6 +34,11 @@ try {
} catch (Throwable) {
$remarkMap = [];
}
try {
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
} catch (Throwable) {
$friendAliasMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
@@ -41,16 +46,7 @@ try {
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = '';
try {
$aliasResult = requireImSuccess(
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
'联系人备注加载失败'
);
$alias = trim((string)($aliasResult['alias'] ?? ''));
} catch (Throwable $error) {
$alias = '';
}
$alias = $friendAliasMap[$uid] ?? '';
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
+194 -11
View File
@@ -70,6 +70,8 @@ function webGroupConversation(array $group, string $role = 'member'): array
'line' => 0,
'category' => $category,
'role' => $role,
'nativeStatus' => (string)($group['native_status'] ?? ''),
'nativeError' => (string)($group['native_error'] ?? ''),
],
];
}
@@ -176,6 +178,48 @@ function createNativeGroup(string $operator, string $groupId, string $name, stri
]), '原生群组创建失败');
}
function fetchNativeGroupInfo(string $groupKey): array
{
$payloads = [
['group_id' => $groupKey],
['groupId' => $groupKey],
['target_id' => $groupKey],
];
$lastError = null;
foreach ($payloads as $payload) {
try {
return requireImSuccess(imAdminPost('/admin/group/get_info', $payload), '群组不存在');
} catch (Throwable $error) {
$lastError = $error;
}
}
throw new RuntimeException($lastError ? ($lastError->getMessage() ?: '群组不存在') : '群组不存在');
}
function nativeGroupInfoValue(array $native, string $key, string $fallback = ''): string
{
$sources = [$native];
if (is_array($native['group_info'] ?? null)) {
$sources[] = $native['group_info'];
}
if (is_array($native['group'] ?? null)) {
$sources[] = $native['group'];
if (is_array($native['group']['group_info'] ?? null)) {
$sources[] = $native['group']['group_info'];
}
}
foreach ($sources as $source) {
if (isset($source[$key]) && (string)$source[$key] !== '') {
return (string)$source[$key];
}
}
return $fallback;
}
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
{
requireImSuccess(imAdminPost('/admin/group/member/add', [
@@ -425,11 +469,134 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
];
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function fetchNativeGroupWithMember(PDO $imDb, string $groupId, string $userId): array
{
$statement = $imDb->prepare(
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
. ' from `t_group` g'
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
);
$statement->execute([$groupId, $userId]);
$group = $statement->fetch();
return is_array($group) ? $group : [];
}
function ensureNativeGroupMember(PDO $imDb, string $groupId, string $userId): array
{
$group = fetchNativeGroupWithMember($imDb, $groupId, $userId);
if ($group) {
return $group;
}
$exists = $imDb->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$exists->execute([$groupId]);
if ($exists->fetchColumn()) {
respond(403, '你不在该群组中');
}
respond(404, '没有找到该群组');
}
function nativeGroupDetailAction(PDO $imDb, string $userId, string $groupId): array
{
$group = ensureNativeGroupMember($imDb, $groupId, $userId);
$ownerId = (string)($group['_owner'] ?? '');
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
$myAlias = (string)($group['member_alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['_gid'],
'name' => (string)($group['_name'] ?? ''),
'category' => 'group',
'announcement' => '',
'welcomeText' => '',
'ownerUid' => $ownerId,
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
'myRole' => nativeGroupRoleFromType($group['member_type'] ?? 0),
'myAlias' => $myAlias,
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
'muteSetting' => [
'mode' => 'off',
'scheduleType' => 'once',
'startsAtMs' => 0,
'endsAtMs' => 0,
'dailyStart' => '',
'dailyEnd' => '',
'enabled' => false,
],
'native' => true,
],
];
}
function nativeGroupMembersAction(PDO $imDb, string $userId, string $groupId): array
{
ensureNativeGroupMember($imDb, $groupId, $userId);
$statement = $imDb->prepare(
'select `_mid`, `_alias`, `_type`, `_create_dt` from `t_group_member`'
. ' where `_gid` = ? order by (`_type` = 2) desc, (`_type` = 1) desc, `_create_dt` asc limit 500'
);
$statement->execute([$groupId]);
$members = $statement->fetchAll();
$users = fetchWebUserMap($imDb, array_column($members, '_mid'), $userId);
return [
'members' => array_map(static function (array $member) use ($users): array {
$uid = (string)$member['_mid'];
$user = $users[$uid] ?? [
'uid' => $uid,
'displayName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'friendAlias' => '',
'remarkAlias' => '',
'isContact' => false,
];
$alias = trim((string)($member['_alias'] ?? ''));
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
return [
'uid' => $uid,
'role' => nativeGroupRoleFromType($member['_type'] ?? 0),
'alias' => $alias,
'contactAlias' => $contactAlias,
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $members),
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
return nativeGroupDetailAction($imDb, $userId, $groupId);
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
@@ -462,7 +629,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
return nativeGroupMembersAction($imDb, $userId, $groupId);
}
$statement = $appDb->prepare(
@@ -512,7 +679,10 @@ function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, str
respond(400, '本群昵称过长');
}
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
ensureNativeGroupMember(imDb(), $groupId, $userId);
$update = imDb()->prepare('update `t_group_member` set `_alias` = ? where `_gid` = ? and `_mid` = ?');
$update->execute([$alias, $groupId, $userId]);
return ['alias' => $alias];
}
$now = webImNowMs();
@@ -781,8 +951,16 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
$group = fetchWebGroup($appDb, $groupKey);
if (!$group) {
try {
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
$native = fetchNativeGroupInfo($groupKey);
$now = webImNowMs();
$nativeGroupId = nativeGroupInfoValue(
$native,
'target_id',
nativeGroupInfoValue($native, 'targetId', nativeGroupInfoValue($native, 'group_id', nativeGroupInfoValue($native, 'groupId', $groupKey)))
);
$nativeName = nativeGroupInfoValue($native, 'name', $nativeGroupId);
$nativePortrait = nativeGroupInfoValue($native, 'portrait', '');
$nativeOwner = nativeGroupInfoValue($native, 'owner', nativeGroupInfoValue($native, 'owner_uid', nativeGroupInfoValue($native, 'ownerUid', '')));
$statement = $appDb->prepare(
'insert into `t_web_groups`'
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
@@ -791,33 +969,38 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([
$groupKey,
(string)($native['name'] ?? $groupKey),
(string)($native['portrait'] ?? ''),
(string)($native['owner'] ?? ''),
$groupKey,
$nativeGroupId,
$nativeName,
$nativePortrait,
$nativeOwner,
$nativeGroupId,
$userId,
$now,
$now,
]);
$group = fetchWebGroup($appDb, $groupKey);
$group = fetchWebGroup($appDb, $nativeGroupId);
} catch (Throwable) {
respond(404, '没有找到该群组');
}
}
$groupId = (string)$group['group_id'];
$nativeStatus = (string)($group['native_status'] ?? '');
$nativeError = (string)($group['native_error'] ?? '');
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
try {
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
} catch (Throwable $error) {
// Web membership is authoritative for this web build; native group sync is best-effort.
$nativeStatus = $nativeStatus === 'ok' ? 'partial' : ($nativeStatus ?: 'fallback');
$nativeError = mb_substr($error->getMessage() ?: '加入原生群组失败', 0, 255, 'UTF-8');
}
$role = fetchWebGroupRole($appDb, $groupId, $userId);
return [
'group' => fetchWebGroup($appDb, $groupId),
'conversation' => webGroupConversation($group, $role),
'nativeStatus' => $nativeStatus,
'nativeError' => $nativeError,
];
}
+25
View File
@@ -355,6 +355,31 @@ function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): arra
return $remarks;
}
function fetchImFriendAliasMap(PDO $db, string $ownerId, array $friendIds): array
{
$friendIds = array_values(array_unique(array_filter(array_map(static function ($friendId): string {
return trim((string)$friendId);
}, $friendIds))));
if ($ownerId === '' || !$friendIds) {
return [];
}
$aliases = [];
foreach (array_chunk($friendIds, 500) as $chunk) {
$statement = $db->prepare(
'select `_friend_uid`, `_alias` from `t_friend`'
. ' where `_uid` = ? and `_friend_uid` in (' . webImPlaceholders($chunk) . ') and `_state` = 0'
);
$statement->execute(array_merge([$ownerId], $chunk));
foreach ($statement->fetchAll() as $row) {
$aliases[(string)$row['_friend_uid']] = trim((string)($row['_alias'] ?? ''));
}
}
return $aliases;
}
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
{
if ($ownerId === '' || $targetId === '') {
+677 -9
View File
@@ -132,7 +132,7 @@ function fetchMessageRows(PDO $db, array $messageIds): array
$messages = [];
foreach ($idsByTable as $table => $ids) {
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
$statement = $db->prepare($sql);
$statement->execute($ids);
@@ -232,12 +232,21 @@ function messagePreviewText(?array $message): string
return '';
}
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
$contentType = (int)($message['contentType'] ?? 0);
$messageType = (string)($message['type'] ?? '');
$placeholderIsImage = $contentType === 3 || $messageType === 'image';
$text = trim((string)($message['text'] ?? ''));
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
$text = '';
}
if ($placeholderIsImage && ($text === '[图片]' || $text === '图片')) {
$text = '';
}
if ($attachment) {
$text = trim((string)($message['text'] ?? ''));
return $text !== '' ? $text : attachmentFallbackText($attachment);
}
return (string)($message['text'] ?? '');
return $text;
}
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
@@ -405,6 +414,42 @@ function isWebGroupTarget(PDO $appDb, string $targetId): bool
return (bool)$statement->fetchColumn();
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function isNativeGroupTarget(PDO $db, string $targetId): bool
{
if ($targetId === '') {
return false;
}
$statement = $db->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$statement->execute([$targetId]);
return (bool)$statement->fetchColumn();
}
function nativeGroupRole(PDO $db, string $userId, string $groupId): string
{
$statement = $db->prepare(
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
);
$statement->execute([$groupId, $userId]);
$type = $statement->fetchColumn();
return $type === false ? '' : nativeGroupRoleFromType($type);
}
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
{
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
@@ -422,6 +467,34 @@ function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $time
$statement->execute([$groupId, $userId, $timestamp, $now]);
}
function markNativeGroupRead(PDO $db, string $userId, string $groupId, int $timestamp): void
{
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
return;
}
$statement = $db->prepare(
'select `id`, `_dt` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
. ' order by `_dt` desc limit 1'
);
$statement->execute([$userId, $groupId]);
$row = $statement->fetch();
if ($row) {
if ($timestamp > (int)$row['_dt']) {
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
$update->execute([$timestamp, (int)$row['id']]);
}
return;
}
$insert = $db->prepare(
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 1, 0, ?, ?)'
);
$insert->execute([$userId, $groupId, $timestamp]);
}
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
{
$statement = $appDb->prepare(
@@ -434,6 +507,50 @@ function webGroupRole(PDO $appDb, string $userId, string $groupId): string
return is_string($role) && $role !== '' ? $role : '';
}
function loadNativeGroupReadStateMap(PDO $db, string $userId, array $groupIds): array
{
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
if ($userId === '' || !$groupIds) {
return [];
}
$statement = $db->prepare(
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
. ' and `_target` in (' . placeholders($groupIds) . ')'
. ' group by `_target`'
);
$statement->execute(array_merge([$userId], $groupIds));
$readAtByGroup = [];
foreach ($statement->fetchAll() as $row) {
$readAtByGroup[(string)$row['_target']] = (int)$row['_read_at'];
}
return $readAtByGroup;
}
function fetchNativeGroupMemberMap(PDO $db, string $groupId, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if ($groupId === '' || !$userIds) {
return [];
}
$statement = $db->prepare(
'select `_mid`, `_alias`, `_type`, `_dt`, `_create_dt` from `t_group_member`'
. ' where `_gid` = ? and `_mid` in (' . placeholders($userIds) . ')'
);
$statement->execute(array_merge([$groupId], $userIds));
$members = [];
foreach ($statement->fetchAll() as $row) {
$members[(string)$row['_mid']] = $row;
}
return $members;
}
function formatWebGroupMessage(
array $row,
string $currentUserId,
@@ -508,6 +625,314 @@ function formatWebGroupMessage(
];
}
function cleanupNativeMediaUrl(string $url): string
{
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 (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]+~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' ? '图片' : '文件';
}
function nativeAttachmentMime(string $url, string $type): string
{
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 ($contentType !== 3 && $contentType !== 5) {
return null;
}
$url = extractNativeMediaUrlFromData($row['_data'] ?? '');
if ($url === '') {
return null;
}
$type = $contentType === 3 ? 'image' : '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)) {
return '';
}
if ($text === '' && !$isRecalled) {
return nativeMessageFallbackText($contentType, $hasAttachment);
}
return $text;
}
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
{
if ($hasAttachment) {
return '';
}
if ($contentType === 5) {
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);
$isRecalled = $contentType === 80;
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$text = 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,
'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,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
'recalled' => $isRecalled,
'recallStatus' => $isRecalled ? '已撤回' : '',
'editedAt' => 0,
'timestamp' => $timestamp,
'activityTimestamp' => $timestamp,
'displayTimestamp' => $timestamp,
'conversationType' => 'group',
'raw' => [
'native' => true,
'groupName' => (string)($group['_name'] ?? ''),
],
];
}
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
{
ensureWebImAppTables();
@@ -557,6 +982,81 @@ function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $
return $messages;
}
function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $limit, bool $markRead): array
{
$role = nativeGroupRole($db, $userId, $groupId);
if ($role === '') {
return [];
}
$groupStatement = $db->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$group = $groupStatement->fetch();
if (!is_array($group)) {
return [];
}
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 4, $limit), 400);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
. ' order by `_mid` desc limit ' . $scanLimit
);
$statement->execute([$userId, $groupId]);
$userRows = $statement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
$globalRows = loadNativeGroupRowsFromGlobal($db, [$groupId], $scanLimit);
foreach ($globalRows as $row) {
$messageId = (string)$row['_mid'];
if ($messageId !== '') {
$messageRows[$messageId] = $row;
$userRows[] = [
'_mid' => $messageId,
'_dt' => (string)($row['_dt'] ?? ''),
];
}
}
usort($userRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
$messages = [];
$seenMessageIds = [];
foreach ($userRows as $userRow) {
$messageId = (string)$userRow['_mid'];
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
continue;
}
$seenMessageIds[$messageId] = true;
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
continue;
}
$messages[] = $messageRows[$messageId];
if (count($messages) >= $limit) {
break;
}
}
$messages = array_reverse($messages);
$senderIds = array_values(array_unique(array_map(static fn(array $row): string => (string)$row['_from'], $messages)));
$users = fetchUserInfoMap($db, $senderIds, $userId);
$memberMap = fetchNativeGroupMemberMap($db, $groupId, $senderIds);
$readTimestamp = 0;
$formatted = [];
foreach ($messages as $row) {
$messageId = (string)$row['_mid'];
$message = formatNativeGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users, $memberMap);
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
$formatted[] = $message;
}
if ($markRead) {
markNativeGroupRead($db, $userId, $groupId, $readTimestamp);
}
return $formatted;
}
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
{
ensureWebImAppTables();
@@ -619,7 +1119,7 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
? (messagePreviewText([
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
'attachment' => $lastAttachment,
]) ?: (string)$lastMessage['text'])
]))
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
$updatedAt = $lastMessage
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
@@ -649,6 +1149,152 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
return $conversations;
}
function loadNativeGroupConversations(PDO $db, string $userId, int $limit): array
{
$statement = $db->prepare(
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
. ' from `t_group_member` m'
. ' join `t_group` g on g.`_gid` = m.`_gid` and g.`_deleted` = 0'
. ' where m.`_mid` = ?'
. ' order by greatest(g.`_dt`, m.`_dt`) desc limit ' . min(max($limit, 50), 100)
);
$statement->execute([$userId]);
$groups = $statement->fetchAll();
if (!$groups) {
return [];
}
$groupIds = array_values(array_unique(array_map(static fn(array $group): string => (string)$group['_gid'], $groups)));
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 20, 200), 1000);
$messageStatement = $db->prepare(
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
. ' and `_target` in (' . placeholders($groupIds) . ')'
. ' order by `_mid` desc limit ' . $scanLimit
);
$messageStatement->execute(array_merge([$userId], $groupIds));
$scanRows = $messageStatement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
$globalRows = loadNativeGroupRowsFromGlobal($db, $groupIds, $scanLimit);
foreach ($globalRows as $row) {
$messageId = (string)$row['_mid'];
if ($messageId !== '') {
$messageRows[$messageId] = $row;
$scanRows[] = [
'_mid' => $messageId,
'_target' => (string)($row['_target'] ?? ''),
'_dt' => (string)($row['_dt'] ?? ''),
];
}
}
usort($scanRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
$readAtByGroup = loadNativeGroupReadStateMap($db, $userId, $groupIds);
$latestByGroup = [];
$unreadByGroup = [];
$seenMessageIds = [];
foreach ($scanRows as $row) {
$messageId = (string)$row['_mid'];
if (isset($seenMessageIds[$messageId])) {
continue;
}
$seenMessageIds[$messageId] = true;
$groupId = (string)$row['_target'];
$message = $messageRows[$messageId] ?? null;
if ($groupId === '' || !$message || !shouldDisplayMessageForUser($message, $userId)) {
continue;
}
$activityTimestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
if (!isset($latestByGroup[$groupId]) || $activityTimestamp > (int)($latestByGroup[$groupId]['_activity_at'] ?? 0)) {
$row['_activity_at'] = $activityTimestamp;
$latestByGroup[$groupId] = $row;
}
if ((string)$message['_from'] !== $userId && !isRecallMessage($message)) {
if ($activityTimestamp > (int)($readAtByGroup[$groupId] ?? 0)) {
$unreadByGroup[$groupId] = (int)($unreadByGroup[$groupId] ?? 0) + 1;
}
}
}
$groupsById = [];
foreach ($groups as $group) {
$groupsById[(string)$group['_gid']] = $group;
}
$senderIds = [];
foreach ($latestByGroup as $row) {
$message = $messageRows[(string)$row['_mid']] ?? null;
if ($message) {
$senderIds[] = (string)$message['_from'];
}
}
$users = fetchUserInfoMap($db, $senderIds, $userId);
$conversations = [];
foreach ($groupsById as $groupId => $group) {
$latestRow = $latestByGroup[$groupId] ?? null;
$messageId = $latestRow ? (string)$latestRow['_mid'] : '';
$message = $messageId !== '' && isset($messageRows[$messageId])
? formatNativeGroupMessage(
$messageRows[$messageId],
$userId,
$group,
$attachmentMap[$messageId] ?? null,
$users,
fetchNativeGroupMemberMap($db, $groupId, [(string)$messageRows[$messageId]['_from']])
)
: null;
$title = (string)($group['_name'] ?? '') ?: $groupId;
$updatedAt = $message ? (int)$message['activityTimestamp'] : (int)($group['_dt'] ?? 0);
$conversations[] = [
'id' => 'group-' . $groupId,
'type' => 'group',
'target' => $groupId,
'title' => $title,
'subtitle' => $message
? (messagePreviewText($message) ?: nativeMessageFallbackText((int)($message['contentType'] ?? 0), false))
: (((int)($group['_member_count'] ?? 0) > 0) ? (int)$group['_member_count'] . ' 位成员' : '群组'),
'unread' => (int)($unreadByGroup[$groupId] ?? 0),
'portrait' => (string)($group['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $updatedAt,
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
'memberCount' => (int)($group['_member_count'] ?? 0),
'raw' => [
'type' => 'group',
'target' => $groupId,
'line' => 0,
'native' => true,
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
],
];
}
return $conversations;
}
function mergeConversationsById(array $conversations, array $extraConversations): array
{
$merged = [];
foreach (array_merge($conversations, $extraConversations) as $conversation) {
$id = (string)($conversation['id'] ?? '');
if ($id === '') {
continue;
}
if (!isset($merged[$id])) {
$merged[$id] = $conversation;
continue;
}
if ((int)($conversation['updatedAt'] ?? 0) > (int)($merged[$id]['updatedAt'] ?? 0)) {
$merged[$id] = array_merge($merged[$id], $conversation);
}
}
return array_values($merged);
}
function isRecallMessage(array $row): bool
{
return (int)($row['_content_type'] ?? 0) === 80;
@@ -679,8 +1325,9 @@ function formatMessage(
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
@@ -854,10 +1501,16 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
}
try {
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
} catch (Throwable) {
$conversations = mergeConversationsById($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
} catch (Throwable $error) {
error_log('web group conversation sync failed: ' . $error->getMessage());
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
}
try {
$conversations = mergeConversationsById($conversations, loadNativeGroupConversations($db, $userId, $limit));
} catch (Throwable $error) {
error_log('native group conversation sync failed: ' . $error->getMessage());
}
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
@@ -881,12 +1534,27 @@ try {
markUserDelivered($db, $userId, nowMs());
if ($target !== '') {
$targetId = resolveUserId($db, $target);
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
if ($conversationType === 'group') {
if (isWebGroupTarget(imAppDb(), $target)) {
respond(0, 'success', [
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
]);
}
if (isNativeGroupTarget($db, $target)) {
respond(0, 'success', [
'messages' => loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
]);
}
respond(0, 'success', [
'messages' => [],
]);
}
if (isWebGroupTarget(imAppDb(), $target)) {
respond(0, 'success', [
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
]);
}
$targetId = resolveUserId($db, $target);
respond(0, 'success', [
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
]);
+219 -55
View File
@@ -6,12 +6,21 @@ header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
define(
'WEB_MEDIA_ROOT',
webImEnv(
'JC_WEB_IM_MEDIA_ROOT',
is_dir('/var/www/im/public/media')
? '/var/www/im/public/media'
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
)
);
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
const WEB_MINIO_PROJECT_CODE = '001';
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
@@ -72,6 +81,33 @@ function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $n
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
{
$statement = $db->prepare(
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
. ' from `t_group` g'
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
);
$statement->execute([$groupId, $sender]);
$group = $statement->fetch();
return is_array($group) ? $group : [];
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
@@ -162,7 +198,106 @@ function mediaPublicBaseUrl(): string
return $scheme . '://' . $host . ':' . $port;
}
function saveMessageAttachmentFile(array $file, string $attachmentType): array
function messageStorageConfig(): array
{
$config = ['enabled' => false, 'url' => ''];
foreach (['t_app_system_conf', 'system_conf'] as $table) {
try {
$statement = imAppDb()->query(
'select `conf_name`, `conf_value` from `' . $table . '`'
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
);
$rows = $statement->fetchAll();
if (!$rows) {
continue;
}
foreach ($rows as $row) {
$name = strtolower((string)($row['conf_name'] ?? ''));
if ($name === 'is_minioplus') {
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
} elseif ($name === 'minio_url') {
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
}
}
break;
} catch (Throwable $error) {
continue;
}
}
return $config;
}
function saveMessageAttachmentToMinio(
array $file,
string $attachmentType,
string $sender,
string $baseUrl
): array {
$size = (int)($file['size'] ?? 0);
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$content = file_get_contents($tmpName);
if ($content === false) {
throw new RuntimeException('读取上传文件失败');
}
$requestBody = json_encode([
'projectCode' => WEB_MINIO_PROJECT_CODE,
'fileScene' => 'temp',
'fileName' => $originalName,
'contentType' => $mime,
'base64Content' => base64_encode($content),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($requestBody)) {
throw new RuntimeException('生成图片上传请求失败');
}
$handle = curl_init($baseUrl . '/project-storage/upload');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
],
CURLOPT_POSTFIELDS => $requestBody,
]);
$responseBody = curl_exec($handle);
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($handle);
curl_close($handle);
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
$message = trim((string)($response['message'] ?? ''));
throw new RuntimeException(
$message !== ''
? 'MinIO上传失败:' . $message
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
);
}
return [
'type' => $attachmentType,
'url' => $url,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
];
}
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
@@ -172,10 +307,6 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
@@ -185,6 +316,17 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$storage = messageStorageConfig();
if ($storage['enabled']) {
if ($storage['url'] === '') {
throw new RuntimeException('MinIO已开启但未配置服务地址');
}
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
@@ -249,7 +391,7 @@ function storeMessageAttachment(
]);
}
function attachmentFromRequest(string $messageType): ?array
function attachmentFromRequest(string $messageType, string $sender): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
@@ -260,7 +402,61 @@ function attachmentFromRequest(string $messageType): ?array
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType);
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
}
function messagePayloadBody(string $text, ?array $attachment): array
{
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
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);
}
return $payloadBody;
}
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
{
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '原生群消息发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
ensureWebImAppTables();
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
return [
'messageUid' => $messageUid !== '' ? $messageUid : null,
'timestamp' => $timestamp,
'attachment' => $attachment,
];
}
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
@@ -292,7 +488,17 @@ try {
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
if (!$nativeGroup) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType, $sender);
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
respond(0, 'success', [
...$nativeResult,
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
'native' => true,
]);
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
@@ -304,7 +510,7 @@ try {
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
respond(403, '当前群组处于禁言时段');
}
$attachment = attachmentFromRequest($messageType);
$attachment = attachmentFromRequest($messageType, $sender);
$messageUid = webImMessageUid();
$insert = $appDb->prepare(
'insert into `t_web_group_messages`'
@@ -320,27 +526,6 @@ try {
$nativeError = '';
try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
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);
}
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
@@ -348,7 +533,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
@@ -364,28 +549,7 @@ try {
]);
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
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);
}
$attachment = attachmentFromRequest($messageType, $sender);
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
@@ -394,7 +558,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '发送失败');
+5 -1
View File
@@ -12,6 +12,10 @@
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'http'
sdkMode: 'http',
syncIntervalMs: 15000,
sseEnabled: true,
sseTicketApi: '/api/sse_ticket.php',
sseUrl: '/ws-events'
};
})();
+126 -4
View File
@@ -1257,6 +1257,14 @@ button {
padding: 24px;
}
.empty-list-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.empty-chat {
min-height: 0;
padding: 24px;
@@ -1917,6 +1925,17 @@ button {
font-size: 12px;
}
.group-member-row .group-member-role {
width: -moz-fit-content;
width: fit-content;
padding: 2px 7px;
border: 1px solid #b8d8d1;
border-radius: 5px;
background: #edf8f5;
color: var(--primary);
font-weight: 700;
}
.group-management-dialog {
width: min(520px, calc(100% - 32px));
}
@@ -1977,7 +1996,9 @@ button {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 24px;
padding: 24px 24px 72px;
scroll-padding-bottom: 72px;
scrollbar-gutter: stable;
}
.message-row {
@@ -2793,9 +2814,11 @@ button {
}
.composer-tools button {
width: 32px;
height: 32px;
border: 0;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--muted);
@@ -2806,6 +2829,105 @@ button {
background: #edf2f5;
}
.composer-tools button:disabled {
cursor: default;
opacity: 0.45;
}
.composer-attachment-preview {
display: flex;
gap: 8px;
padding: 10px 16px 0;
overflow-x: auto;
}
.composer-attachment-item {
position: relative;
width: 88px;
min-width: 88px;
height: 88px;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: #edf2f5;
}
.composer-attachment-item img {
width: 100%;
height: 100%;
display: block;
-o-object-fit: cover;
object-fit: cover;
}
.composer-attachment-file-icon {
color: var(--primary);
font-size: 30px;
}
.composer-attachment-name {
position: absolute;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
padding: 4px 6px;
background: rgba(18, 28, 40, 0.72);
color: #fff;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer-attachment-item > button {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(18, 28, 40, 0.74);
color: #fff;
font-size: 17px;
line-height: 1;
}
.composer-tool-button {
border-color: rgba(23, 125, 114, 0.28);
background: rgba(23, 125, 114, 0.1);
color: var(--primary);
}
.composer-tool-button.file-tool {
border-color: rgba(216, 78, 63, 0.28);
background: rgba(216, 78, 63, 0.1);
color: var(--accent);
}
.composer-tool-button:hover {
border-color: currentColor;
background: rgba(23, 125, 114, 0.16);
}
.composer-tool-button.file-tool:hover {
background: rgba(216, 78, 63, 0.16);
}
.composer-tool-icon {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.emoji-picker {
position: absolute;
left: 16px;
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.075405dd.js"></script><script defer="defer" src="js/app.ec5edb4d.js"></script><link href="css/app.f8454e8e.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="img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.86756863.js"></script><script defer="defer" src="js/app.5863459a.js"></script><link href="css/app.2c52dabb.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
-1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "../node_modules/.bin/vue-cli-service serve --host 0.0.0.0 --port 8023",
"build": "../node_modules/.bin/vue-cli-service build",
"lint": "../node_modules/.bin/vue-cli-service lint"
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8023",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@vueuse/core": "^10.8.0",
+6 -10
View File
@@ -34,6 +34,11 @@ try {
} catch (Throwable) {
$remarkMap = [];
}
try {
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
} catch (Throwable) {
$friendAliasMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
@@ -41,16 +46,7 @@ try {
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = '';
try {
$aliasResult = requireImSuccess(
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
'联系人备注加载失败'
);
$alias = trim((string)($aliasResult['alias'] ?? ''));
} catch (Throwable $error) {
$alias = '';
}
$alias = $friendAliasMap[$uid] ?? '';
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
+194 -11
View File
@@ -70,6 +70,8 @@ function webGroupConversation(array $group, string $role = 'member'): array
'line' => 0,
'category' => $category,
'role' => $role,
'nativeStatus' => (string)($group['native_status'] ?? ''),
'nativeError' => (string)($group['native_error'] ?? ''),
],
];
}
@@ -176,6 +178,48 @@ function createNativeGroup(string $operator, string $groupId, string $name, stri
]), '原生群组创建失败');
}
function fetchNativeGroupInfo(string $groupKey): array
{
$payloads = [
['group_id' => $groupKey],
['groupId' => $groupKey],
['target_id' => $groupKey],
];
$lastError = null;
foreach ($payloads as $payload) {
try {
return requireImSuccess(imAdminPost('/admin/group/get_info', $payload), '群组不存在');
} catch (Throwable $error) {
$lastError = $error;
}
}
throw new RuntimeException($lastError ? ($lastError->getMessage() ?: '群组不存在') : '群组不存在');
}
function nativeGroupInfoValue(array $native, string $key, string $fallback = ''): string
{
$sources = [$native];
if (is_array($native['group_info'] ?? null)) {
$sources[] = $native['group_info'];
}
if (is_array($native['group'] ?? null)) {
$sources[] = $native['group'];
if (is_array($native['group']['group_info'] ?? null)) {
$sources[] = $native['group']['group_info'];
}
}
foreach ($sources as $source) {
if (isset($source[$key]) && (string)$source[$key] !== '') {
return (string)$source[$key];
}
}
return $fallback;
}
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
{
requireImSuccess(imAdminPost('/admin/group/member/add', [
@@ -425,11 +469,134 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
];
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function fetchNativeGroupWithMember(PDO $imDb, string $groupId, string $userId): array
{
$statement = $imDb->prepare(
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
. ' from `t_group` g'
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
);
$statement->execute([$groupId, $userId]);
$group = $statement->fetch();
return is_array($group) ? $group : [];
}
function ensureNativeGroupMember(PDO $imDb, string $groupId, string $userId): array
{
$group = fetchNativeGroupWithMember($imDb, $groupId, $userId);
if ($group) {
return $group;
}
$exists = $imDb->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$exists->execute([$groupId]);
if ($exists->fetchColumn()) {
respond(403, '你不在该群组中');
}
respond(404, '没有找到该群组');
}
function nativeGroupDetailAction(PDO $imDb, string $userId, string $groupId): array
{
$group = ensureNativeGroupMember($imDb, $groupId, $userId);
$ownerId = (string)($group['_owner'] ?? '');
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
$myAlias = (string)($group['member_alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['_gid'],
'name' => (string)($group['_name'] ?? ''),
'category' => 'group',
'announcement' => '',
'welcomeText' => '',
'ownerUid' => $ownerId,
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
'myRole' => nativeGroupRoleFromType($group['member_type'] ?? 0),
'myAlias' => $myAlias,
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
'muteSetting' => [
'mode' => 'off',
'scheduleType' => 'once',
'startsAtMs' => 0,
'endsAtMs' => 0,
'dailyStart' => '',
'dailyEnd' => '',
'enabled' => false,
],
'native' => true,
],
];
}
function nativeGroupMembersAction(PDO $imDb, string $userId, string $groupId): array
{
ensureNativeGroupMember($imDb, $groupId, $userId);
$statement = $imDb->prepare(
'select `_mid`, `_alias`, `_type`, `_create_dt` from `t_group_member`'
. ' where `_gid` = ? order by (`_type` = 2) desc, (`_type` = 1) desc, `_create_dt` asc limit 500'
);
$statement->execute([$groupId]);
$members = $statement->fetchAll();
$users = fetchWebUserMap($imDb, array_column($members, '_mid'), $userId);
return [
'members' => array_map(static function (array $member) use ($users): array {
$uid = (string)$member['_mid'];
$user = $users[$uid] ?? [
'uid' => $uid,
'displayName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'friendAlias' => '',
'remarkAlias' => '',
'isContact' => false,
];
$alias = trim((string)($member['_alias'] ?? ''));
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
return [
'uid' => $uid,
'role' => nativeGroupRoleFromType($member['_type'] ?? 0),
'alias' => $alias,
'contactAlias' => $contactAlias,
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $members),
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
return nativeGroupDetailAction($imDb, $userId, $groupId);
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
@@ -462,7 +629,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
return nativeGroupMembersAction($imDb, $userId, $groupId);
}
$statement = $appDb->prepare(
@@ -512,7 +679,10 @@ function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, str
respond(400, '本群昵称过长');
}
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
ensureNativeGroupMember(imDb(), $groupId, $userId);
$update = imDb()->prepare('update `t_group_member` set `_alias` = ? where `_gid` = ? and `_mid` = ?');
$update->execute([$alias, $groupId, $userId]);
return ['alias' => $alias];
}
$now = webImNowMs();
@@ -781,8 +951,16 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
$group = fetchWebGroup($appDb, $groupKey);
if (!$group) {
try {
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
$native = fetchNativeGroupInfo($groupKey);
$now = webImNowMs();
$nativeGroupId = nativeGroupInfoValue(
$native,
'target_id',
nativeGroupInfoValue($native, 'targetId', nativeGroupInfoValue($native, 'group_id', nativeGroupInfoValue($native, 'groupId', $groupKey)))
);
$nativeName = nativeGroupInfoValue($native, 'name', $nativeGroupId);
$nativePortrait = nativeGroupInfoValue($native, 'portrait', '');
$nativeOwner = nativeGroupInfoValue($native, 'owner', nativeGroupInfoValue($native, 'owner_uid', nativeGroupInfoValue($native, 'ownerUid', '')));
$statement = $appDb->prepare(
'insert into `t_web_groups`'
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
@@ -791,33 +969,38 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([
$groupKey,
(string)($native['name'] ?? $groupKey),
(string)($native['portrait'] ?? ''),
(string)($native['owner'] ?? ''),
$groupKey,
$nativeGroupId,
$nativeName,
$nativePortrait,
$nativeOwner,
$nativeGroupId,
$userId,
$now,
$now,
]);
$group = fetchWebGroup($appDb, $groupKey);
$group = fetchWebGroup($appDb, $nativeGroupId);
} catch (Throwable) {
respond(404, '没有找到该群组');
}
}
$groupId = (string)$group['group_id'];
$nativeStatus = (string)($group['native_status'] ?? '');
$nativeError = (string)($group['native_error'] ?? '');
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
try {
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
} catch (Throwable $error) {
// Web membership is authoritative for this web build; native group sync is best-effort.
$nativeStatus = $nativeStatus === 'ok' ? 'partial' : ($nativeStatus ?: 'fallback');
$nativeError = mb_substr($error->getMessage() ?: '加入原生群组失败', 0, 255, 'UTF-8');
}
$role = fetchWebGroupRole($appDb, $groupId, $userId);
return [
'group' => fetchWebGroup($appDb, $groupId),
'conversation' => webGroupConversation($group, $role),
'nativeStatus' => $nativeStatus,
'nativeError' => $nativeError,
];
}
+25
View File
@@ -355,6 +355,31 @@ function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): arra
return $remarks;
}
function fetchImFriendAliasMap(PDO $db, string $ownerId, array $friendIds): array
{
$friendIds = array_values(array_unique(array_filter(array_map(static function ($friendId): string {
return trim((string)$friendId);
}, $friendIds))));
if ($ownerId === '' || !$friendIds) {
return [];
}
$aliases = [];
foreach (array_chunk($friendIds, 500) as $chunk) {
$statement = $db->prepare(
'select `_friend_uid`, `_alias` from `t_friend`'
. ' where `_uid` = ? and `_friend_uid` in (' . webImPlaceholders($chunk) . ') and `_state` = 0'
);
$statement->execute(array_merge([$ownerId], $chunk));
foreach ($statement->fetchAll() as $row) {
$aliases[(string)$row['_friend_uid']] = trim((string)($row['_alias'] ?? ''));
}
}
return $aliases;
}
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
{
if ($ownerId === '' || $targetId === '') {
+677 -9
View File
@@ -132,7 +132,7 @@ function fetchMessageRows(PDO $db, array $messageIds): array
$messages = [];
foreach ($idsByTable as $table => $ids) {
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
$statement = $db->prepare($sql);
$statement->execute($ids);
@@ -232,12 +232,21 @@ function messagePreviewText(?array $message): string
return '';
}
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
$contentType = (int)($message['contentType'] ?? 0);
$messageType = (string)($message['type'] ?? '');
$placeholderIsImage = $contentType === 3 || $messageType === 'image';
$text = trim((string)($message['text'] ?? ''));
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
$text = '';
}
if ($placeholderIsImage && ($text === '[图片]' || $text === '图片')) {
$text = '';
}
if ($attachment) {
$text = trim((string)($message['text'] ?? ''));
return $text !== '' ? $text : attachmentFallbackText($attachment);
}
return (string)($message['text'] ?? '');
return $text;
}
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
@@ -405,6 +414,42 @@ function isWebGroupTarget(PDO $appDb, string $targetId): bool
return (bool)$statement->fetchColumn();
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function isNativeGroupTarget(PDO $db, string $targetId): bool
{
if ($targetId === '') {
return false;
}
$statement = $db->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$statement->execute([$targetId]);
return (bool)$statement->fetchColumn();
}
function nativeGroupRole(PDO $db, string $userId, string $groupId): string
{
$statement = $db->prepare(
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
);
$statement->execute([$groupId, $userId]);
$type = $statement->fetchColumn();
return $type === false ? '' : nativeGroupRoleFromType($type);
}
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
{
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
@@ -422,6 +467,34 @@ function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $time
$statement->execute([$groupId, $userId, $timestamp, $now]);
}
function markNativeGroupRead(PDO $db, string $userId, string $groupId, int $timestamp): void
{
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
return;
}
$statement = $db->prepare(
'select `id`, `_dt` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
. ' order by `_dt` desc limit 1'
);
$statement->execute([$userId, $groupId]);
$row = $statement->fetch();
if ($row) {
if ($timestamp > (int)$row['_dt']) {
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
$update->execute([$timestamp, (int)$row['id']]);
}
return;
}
$insert = $db->prepare(
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 1, 0, ?, ?)'
);
$insert->execute([$userId, $groupId, $timestamp]);
}
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
{
$statement = $appDb->prepare(
@@ -434,6 +507,50 @@ function webGroupRole(PDO $appDb, string $userId, string $groupId): string
return is_string($role) && $role !== '' ? $role : '';
}
function loadNativeGroupReadStateMap(PDO $db, string $userId, array $groupIds): array
{
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
if ($userId === '' || !$groupIds) {
return [];
}
$statement = $db->prepare(
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
. ' and `_target` in (' . placeholders($groupIds) . ')'
. ' group by `_target`'
);
$statement->execute(array_merge([$userId], $groupIds));
$readAtByGroup = [];
foreach ($statement->fetchAll() as $row) {
$readAtByGroup[(string)$row['_target']] = (int)$row['_read_at'];
}
return $readAtByGroup;
}
function fetchNativeGroupMemberMap(PDO $db, string $groupId, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if ($groupId === '' || !$userIds) {
return [];
}
$statement = $db->prepare(
'select `_mid`, `_alias`, `_type`, `_dt`, `_create_dt` from `t_group_member`'
. ' where `_gid` = ? and `_mid` in (' . placeholders($userIds) . ')'
);
$statement->execute(array_merge([$groupId], $userIds));
$members = [];
foreach ($statement->fetchAll() as $row) {
$members[(string)$row['_mid']] = $row;
}
return $members;
}
function formatWebGroupMessage(
array $row,
string $currentUserId,
@@ -508,6 +625,314 @@ function formatWebGroupMessage(
];
}
function cleanupNativeMediaUrl(string $url): string
{
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 (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]+~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' ? '图片' : '文件';
}
function nativeAttachmentMime(string $url, string $type): string
{
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 ($contentType !== 3 && $contentType !== 5) {
return null;
}
$url = extractNativeMediaUrlFromData($row['_data'] ?? '');
if ($url === '') {
return null;
}
$type = $contentType === 3 ? 'image' : '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)) {
return '';
}
if ($text === '' && !$isRecalled) {
return nativeMessageFallbackText($contentType, $hasAttachment);
}
return $text;
}
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
{
if ($hasAttachment) {
return '';
}
if ($contentType === 5) {
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);
$isRecalled = $contentType === 80;
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$text = 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,
'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,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
'recalled' => $isRecalled,
'recallStatus' => $isRecalled ? '已撤回' : '',
'editedAt' => 0,
'timestamp' => $timestamp,
'activityTimestamp' => $timestamp,
'displayTimestamp' => $timestamp,
'conversationType' => 'group',
'raw' => [
'native' => true,
'groupName' => (string)($group['_name'] ?? ''),
],
];
}
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
{
ensureWebImAppTables();
@@ -557,6 +982,81 @@ function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $
return $messages;
}
function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $limit, bool $markRead): array
{
$role = nativeGroupRole($db, $userId, $groupId);
if ($role === '') {
return [];
}
$groupStatement = $db->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$group = $groupStatement->fetch();
if (!is_array($group)) {
return [];
}
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 4, $limit), 400);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
. ' order by `_mid` desc limit ' . $scanLimit
);
$statement->execute([$userId, $groupId]);
$userRows = $statement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
$globalRows = loadNativeGroupRowsFromGlobal($db, [$groupId], $scanLimit);
foreach ($globalRows as $row) {
$messageId = (string)$row['_mid'];
if ($messageId !== '') {
$messageRows[$messageId] = $row;
$userRows[] = [
'_mid' => $messageId,
'_dt' => (string)($row['_dt'] ?? ''),
];
}
}
usort($userRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
$messages = [];
$seenMessageIds = [];
foreach ($userRows as $userRow) {
$messageId = (string)$userRow['_mid'];
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
continue;
}
$seenMessageIds[$messageId] = true;
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
continue;
}
$messages[] = $messageRows[$messageId];
if (count($messages) >= $limit) {
break;
}
}
$messages = array_reverse($messages);
$senderIds = array_values(array_unique(array_map(static fn(array $row): string => (string)$row['_from'], $messages)));
$users = fetchUserInfoMap($db, $senderIds, $userId);
$memberMap = fetchNativeGroupMemberMap($db, $groupId, $senderIds);
$readTimestamp = 0;
$formatted = [];
foreach ($messages as $row) {
$messageId = (string)$row['_mid'];
$message = formatNativeGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users, $memberMap);
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
$formatted[] = $message;
}
if ($markRead) {
markNativeGroupRead($db, $userId, $groupId, $readTimestamp);
}
return $formatted;
}
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
{
ensureWebImAppTables();
@@ -619,7 +1119,7 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
? (messagePreviewText([
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
'attachment' => $lastAttachment,
]) ?: (string)$lastMessage['text'])
]))
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
$updatedAt = $lastMessage
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
@@ -649,6 +1149,152 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
return $conversations;
}
function loadNativeGroupConversations(PDO $db, string $userId, int $limit): array
{
$statement = $db->prepare(
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
. ' from `t_group_member` m'
. ' join `t_group` g on g.`_gid` = m.`_gid` and g.`_deleted` = 0'
. ' where m.`_mid` = ?'
. ' order by greatest(g.`_dt`, m.`_dt`) desc limit ' . min(max($limit, 50), 100)
);
$statement->execute([$userId]);
$groups = $statement->fetchAll();
if (!$groups) {
return [];
}
$groupIds = array_values(array_unique(array_map(static fn(array $group): string => (string)$group['_gid'], $groups)));
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 20, 200), 1000);
$messageStatement = $db->prepare(
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
. ' and `_target` in (' . placeholders($groupIds) . ')'
. ' order by `_mid` desc limit ' . $scanLimit
);
$messageStatement->execute(array_merge([$userId], $groupIds));
$scanRows = $messageStatement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
$globalRows = loadNativeGroupRowsFromGlobal($db, $groupIds, $scanLimit);
foreach ($globalRows as $row) {
$messageId = (string)$row['_mid'];
if ($messageId !== '') {
$messageRows[$messageId] = $row;
$scanRows[] = [
'_mid' => $messageId,
'_target' => (string)($row['_target'] ?? ''),
'_dt' => (string)($row['_dt'] ?? ''),
];
}
}
usort($scanRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
$readAtByGroup = loadNativeGroupReadStateMap($db, $userId, $groupIds);
$latestByGroup = [];
$unreadByGroup = [];
$seenMessageIds = [];
foreach ($scanRows as $row) {
$messageId = (string)$row['_mid'];
if (isset($seenMessageIds[$messageId])) {
continue;
}
$seenMessageIds[$messageId] = true;
$groupId = (string)$row['_target'];
$message = $messageRows[$messageId] ?? null;
if ($groupId === '' || !$message || !shouldDisplayMessageForUser($message, $userId)) {
continue;
}
$activityTimestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
if (!isset($latestByGroup[$groupId]) || $activityTimestamp > (int)($latestByGroup[$groupId]['_activity_at'] ?? 0)) {
$row['_activity_at'] = $activityTimestamp;
$latestByGroup[$groupId] = $row;
}
if ((string)$message['_from'] !== $userId && !isRecallMessage($message)) {
if ($activityTimestamp > (int)($readAtByGroup[$groupId] ?? 0)) {
$unreadByGroup[$groupId] = (int)($unreadByGroup[$groupId] ?? 0) + 1;
}
}
}
$groupsById = [];
foreach ($groups as $group) {
$groupsById[(string)$group['_gid']] = $group;
}
$senderIds = [];
foreach ($latestByGroup as $row) {
$message = $messageRows[(string)$row['_mid']] ?? null;
if ($message) {
$senderIds[] = (string)$message['_from'];
}
}
$users = fetchUserInfoMap($db, $senderIds, $userId);
$conversations = [];
foreach ($groupsById as $groupId => $group) {
$latestRow = $latestByGroup[$groupId] ?? null;
$messageId = $latestRow ? (string)$latestRow['_mid'] : '';
$message = $messageId !== '' && isset($messageRows[$messageId])
? formatNativeGroupMessage(
$messageRows[$messageId],
$userId,
$group,
$attachmentMap[$messageId] ?? null,
$users,
fetchNativeGroupMemberMap($db, $groupId, [(string)$messageRows[$messageId]['_from']])
)
: null;
$title = (string)($group['_name'] ?? '') ?: $groupId;
$updatedAt = $message ? (int)$message['activityTimestamp'] : (int)($group['_dt'] ?? 0);
$conversations[] = [
'id' => 'group-' . $groupId,
'type' => 'group',
'target' => $groupId,
'title' => $title,
'subtitle' => $message
? (messagePreviewText($message) ?: nativeMessageFallbackText((int)($message['contentType'] ?? 0), false))
: (((int)($group['_member_count'] ?? 0) > 0) ? (int)$group['_member_count'] . ' 位成员' : '群组'),
'unread' => (int)($unreadByGroup[$groupId] ?? 0),
'portrait' => (string)($group['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $updatedAt,
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
'memberCount' => (int)($group['_member_count'] ?? 0),
'raw' => [
'type' => 'group',
'target' => $groupId,
'line' => 0,
'native' => true,
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
],
];
}
return $conversations;
}
function mergeConversationsById(array $conversations, array $extraConversations): array
{
$merged = [];
foreach (array_merge($conversations, $extraConversations) as $conversation) {
$id = (string)($conversation['id'] ?? '');
if ($id === '') {
continue;
}
if (!isset($merged[$id])) {
$merged[$id] = $conversation;
continue;
}
if ((int)($conversation['updatedAt'] ?? 0) > (int)($merged[$id]['updatedAt'] ?? 0)) {
$merged[$id] = array_merge($merged[$id], $conversation);
}
}
return array_values($merged);
}
function isRecallMessage(array $row): bool
{
return (int)($row['_content_type'] ?? 0) === 80;
@@ -679,8 +1325,9 @@ function formatMessage(
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$direction = $from === $currentUserId ? 'out' : 'in';
$attachment = nativeAttachmentFromRow($row, $attachment);
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
@@ -854,10 +1501,16 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
}
try {
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
} catch (Throwable) {
$conversations = mergeConversationsById($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
} catch (Throwable $error) {
error_log('web group conversation sync failed: ' . $error->getMessage());
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
}
try {
$conversations = mergeConversationsById($conversations, loadNativeGroupConversations($db, $userId, $limit));
} catch (Throwable $error) {
error_log('native group conversation sync failed: ' . $error->getMessage());
}
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
@@ -881,12 +1534,27 @@ try {
markUserDelivered($db, $userId, nowMs());
if ($target !== '') {
$targetId = resolveUserId($db, $target);
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
if ($conversationType === 'group') {
if (isWebGroupTarget(imAppDb(), $target)) {
respond(0, 'success', [
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
]);
}
if (isNativeGroupTarget($db, $target)) {
respond(0, 'success', [
'messages' => loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
]);
}
respond(0, 'success', [
'messages' => [],
]);
}
if (isWebGroupTarget(imAppDb(), $target)) {
respond(0, 'success', [
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
]);
}
$targetId = resolveUserId($db, $target);
respond(0, 'success', [
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
]);
+219 -55
View File
@@ -6,12 +6,21 @@ header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
define(
'WEB_MEDIA_ROOT',
webImEnv(
'JC_WEB_IM_MEDIA_ROOT',
is_dir('/var/www/im/public/media')
? '/var/www/im/public/media'
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
)
);
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
const WEB_MINIO_PROJECT_CODE = '001';
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
@@ -72,6 +81,33 @@ function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $n
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
{
$statement = $db->prepare(
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
. ' from `t_group` g'
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
);
$statement->execute([$groupId, $sender]);
$group = $statement->fetch();
return is_array($group) ? $group : [];
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
@@ -162,7 +198,106 @@ function mediaPublicBaseUrl(): string
return $scheme . '://' . $host . ':' . $port;
}
function saveMessageAttachmentFile(array $file, string $attachmentType): array
function messageStorageConfig(): array
{
$config = ['enabled' => false, 'url' => ''];
foreach (['t_app_system_conf', 'system_conf'] as $table) {
try {
$statement = imAppDb()->query(
'select `conf_name`, `conf_value` from `' . $table . '`'
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
);
$rows = $statement->fetchAll();
if (!$rows) {
continue;
}
foreach ($rows as $row) {
$name = strtolower((string)($row['conf_name'] ?? ''));
if ($name === 'is_minioplus') {
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
} elseif ($name === 'minio_url') {
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
}
}
break;
} catch (Throwable $error) {
continue;
}
}
return $config;
}
function saveMessageAttachmentToMinio(
array $file,
string $attachmentType,
string $sender,
string $baseUrl
): array {
$size = (int)($file['size'] ?? 0);
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$content = file_get_contents($tmpName);
if ($content === false) {
throw new RuntimeException('读取上传文件失败');
}
$requestBody = json_encode([
'projectCode' => WEB_MINIO_PROJECT_CODE,
'fileScene' => 'temp',
'fileName' => $originalName,
'contentType' => $mime,
'base64Content' => base64_encode($content),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($requestBody)) {
throw new RuntimeException('生成图片上传请求失败');
}
$handle = curl_init($baseUrl . '/project-storage/upload');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
],
CURLOPT_POSTFIELDS => $requestBody,
]);
$responseBody = curl_exec($handle);
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($handle);
curl_close($handle);
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
$message = trim((string)($response['message'] ?? ''));
throw new RuntimeException(
$message !== ''
? 'MinIO上传失败:' . $message
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
);
}
return [
'type' => $attachmentType,
'url' => $url,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
];
}
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
@@ -172,10 +307,6 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
@@ -185,6 +316,17 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$storage = messageStorageConfig();
if ($storage['enabled']) {
if ($storage['url'] === '') {
throw new RuntimeException('MinIO已开启但未配置服务地址');
}
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
@@ -249,7 +391,7 @@ function storeMessageAttachment(
]);
}
function attachmentFromRequest(string $messageType): ?array
function attachmentFromRequest(string $messageType, string $sender): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
@@ -260,7 +402,61 @@ function attachmentFromRequest(string $messageType): ?array
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType);
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
}
function messagePayloadBody(string $text, ?array $attachment): array
{
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
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);
}
return $payloadBody;
}
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
{
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '原生群消息发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
ensureWebImAppTables();
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
return [
'messageUid' => $messageUid !== '' ? $messageUid : null,
'timestamp' => $timestamp,
'attachment' => $attachment,
];
}
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
@@ -292,7 +488,17 @@ try {
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
if (!$nativeGroup) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType, $sender);
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
respond(0, 'success', [
...$nativeResult,
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
'native' => true,
]);
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
@@ -304,7 +510,7 @@ try {
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
respond(403, '当前群组处于禁言时段');
}
$attachment = attachmentFromRequest($messageType);
$attachment = attachmentFromRequest($messageType, $sender);
$messageUid = webImMessageUid();
$insert = $appDb->prepare(
'insert into `t_web_group_messages`'
@@ -320,27 +526,6 @@ try {
$nativeError = '';
try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
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);
}
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
@@ -348,7 +533,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
@@ -364,28 +549,7 @@ try {
]);
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
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);
}
$attachment = attachmentFromRequest($messageType, $sender);
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
@@ -394,7 +558,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '发送失败');
+5 -1
View File
@@ -12,6 +12,10 @@
imRoutePort: 80,
useWss: false,
wsPath: '/ws',
sdkMode: 'http'
sdkMode: 'http',
syncIntervalMs: 15000,
sseEnabled: true,
sseTicketApi: '/api/sse_ticket.php',
sseUrl: '/ws-events'
};
})();
+16 -2
View File
@@ -303,12 +303,15 @@
</header>
<div v-if="groupBusy" class="group-loading">正在加载</div>
<div v-else class="group-member-list">
<div v-for="member in groupMembers" :key="member.uid" class="group-member-row">
<div v-for="member in sortedGroupMembers" :key="member.uid" class="group-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
<small>{{ member.role === 'owner' ? '群主' : member.rawDisplayName }}</small>
<small v-if="detailRoleLabel(member.role)" class="group-member-role">
{{ detailRoleLabel(member.role) }}
</small>
<small v-else>{{ member.rawDisplayName }}</small>
</span>
</div>
<div v-if="!groupMembers.length" class="contact-picker-empty">暂无群员</div>
@@ -576,6 +579,17 @@ const currentGroupRole = computed(() => {
});
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
const sortedGroupMembers = computed(() => {
const roleOrder = { owner: 0, admin: 1, member: 2 };
return groupMembers.value
.map((member, index) => ({ member, index }))
.sort((left, right) => {
const leftOrder = roleOrder[left.member.role] ?? 3;
const rightOrder = roleOrder[right.member.role] ?? 3;
return leftOrder - rightOrder || left.index - right.index;
})
.map(item => item.member);
});
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
const messageDetail = computed(() => conversations.messageDetail || null);
+87 -13
View File
@@ -17,9 +17,36 @@
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
</div>
<div class="composer-tools">
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
<button type="button" title="表情" aria-label="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
<button
type="button"
class="composer-tool-button image-tool"
title="图片"
aria-label="图片"
:disabled="disabled"
@click="chooseImage"
>
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2.5"></rect>
<circle cx="8.2" cy="9.2" r="1.6"></circle>
<path d="M6.5 17l4.2-4.7 3.1 3.4 1.8-2 2.9 3.3"></path>
</svg>
</button>
<button
type="button"
class="composer-tool-button file-tool"
title="文件"
aria-label="文件"
:disabled="disabled"
@click="chooseFile"
>
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 3.8h7.2L19 8.6V20a1.8 1.8 0 0 1-1.8 1.8H7A1.8 1.8 0 0 1 5.2 20V5.6A1.8 1.8 0 0 1 7 3.8z"></path>
<path d="M14 4v5h5"></path>
<path d="M8.5 13h7"></path>
<path d="M8.5 16.5h5.2"></path>
</svg>
</button>
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
@@ -33,6 +60,22 @@
</button>
</div>
</div>
<div v-if="pendingAttachments.length" class="composer-attachment-preview">
<article
v-for="(attachment, index) in pendingAttachments"
:key="attachment.id"
class="composer-attachment-item"
>
<img
v-if="attachment.type === 'image'"
:src="attachment.previewUrl"
:alt="attachment.file.name || '待发送图片'"
>
<span v-else class="composer-attachment-file-icon">▤</span>
<span class="composer-attachment-name">{{ attachment.file.name }}</span>
<button type="button" title="移除" aria-label="移除待发送文件" @click="removePendingAttachment(index)">×</button>
</article>
</div>
<textarea
ref="textareaEl"
v-model="text"
@@ -43,13 +86,13 @@
></textarea>
<div class="composer-footer">
<span>Enter 发送,Shift + Enter 换行</span>
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
<button class="send-button" :disabled="disabled || (!text.trim() && !pendingAttachments.length)">发送</button>
</div>
</form>
</template>
<script setup>
import { ref } from 'vue';
import { onBeforeUnmount, ref } from 'vue';
const props = defineProps({
disabled: {
@@ -69,6 +112,7 @@ const dropActive = ref(false);
const textareaEl = ref(null);
const imageInput = ref(null);
const fileInput = ref(null);
const pendingAttachments = ref([]);
let dragDepth = 0;
const emojiList = [
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
@@ -78,10 +122,22 @@ const emojiList = [
function submit() {
const value = text.value.trim();
if (!value) {
const attachments = [...pendingAttachments.value];
if (!value && !attachments.length) {
return;
}
emit('send', value);
if (attachments.length) {
attachments.forEach((attachment, index) => {
emit('send-attachment', {
type: attachment.type,
file: attachment.file,
text: index === 0 ? value : ''
});
});
clearPendingAttachments();
} else {
emit('send', value);
}
text.value = '';
emojiOpen.value = false;
}
@@ -130,18 +186,34 @@ function emitFiles(files, fallbackType = 'file') {
return;
}
const caption = text.value.trim();
validFiles.forEach((file, index) => {
emit('send-attachment', {
type: attachmentTypeForFile(file, fallbackType),
validFiles.forEach(file => {
const type = attachmentTypeForFile(file, fallbackType);
pendingAttachments.value.push({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
type,
file,
text: index === 0 ? caption : ''
previewUrl: type === 'image' ? URL.createObjectURL(file) : ''
});
});
text.value = '';
emojiOpen.value = false;
}
function releasePendingAttachment(attachment) {
if (attachment?.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
}
}
function removePendingAttachment(index) {
const [attachment] = pendingAttachments.value.splice(index, 1);
releasePendingAttachment(attachment);
}
function clearPendingAttachments() {
pendingAttachments.value.forEach(releasePendingAttachment);
pendingAttachments.value = [];
}
function selectAttachment(event, type) {
const input = event.target;
const files = input.files ? Array.from(input.files) : [];
@@ -220,4 +292,6 @@ function handlePaste(event) {
event.preventDefault();
emitFiles(imageFiles, 'image');
}
onBeforeUnmount(clearPendingAttachments);
</script>
+60 -3
View File
@@ -47,7 +47,13 @@
class="message-image"
@click.stop="handleImageClick(message)"
>
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
<img
:src="message.attachment.url"
:alt="message.attachment.name || '图片'"
loading="lazy"
decoding="async"
@error="retryImageLoad"
>
</button>
<a
v-else-if="isFileMessage(message)"
@@ -63,7 +69,7 @@
<small>{{ formatFileSize(message.attachment.size) }}</small>
</span>
</a>
<p v-if="message.text">{{ message.text }}</p>
<p v-if="displayMessageText(message)">{{ displayMessageText(message) }}</p>
<time>
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
@@ -130,6 +136,8 @@
:src="imageViewer.url"
:alt="imageViewer.name || '图片'"
:style="imageViewerStyle"
decoding="async"
@error="retryImageLoad"
draggable="false"
>
</div>
@@ -325,7 +333,16 @@ const menuItems = computed(() => {
function scrollToBottom() {
nextTick(() => {
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight;
const scroll = () => {
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight;
}
};
scroll();
requestAnimationFrame(() => {
scroll();
requestAnimationFrame(scroll);
});
}
});
}
@@ -372,6 +389,22 @@ function messageIdentity(message) {
return String(message.messageUid || message.id || '');
}
function displayMessageText(message) {
const text = String(message?.text || '').replace(/^引用:.*(?:\r?\n|$)/, '').trim();
const contentType = Number(message?.contentType || 0);
if (!text) {
return '';
}
if (contentType !== 1 && text === '[暂不支持的消息类型]') {
return '';
}
if ((contentType === 3 || message?.type === 'image') && (text === '[图片]' || text === '图片')) {
return '';
}
return text;
}
function canOpenMessageMenu(message) {
return message && message.status !== 'failed' && message.status !== 'sending';
}
@@ -552,6 +585,30 @@ function formatFileSize(size) {
return bytes > 0 ? `${bytes} B` : '';
}
function retryImageLoad(event) {
const image = event.currentTarget;
if (!(image instanceof HTMLImageElement)) {
return;
}
const attempts = Number(image.dataset.loadRetries || 0);
if (attempts >= 2) {
return;
}
const originalUrl = image.dataset.originalUrl || image.getAttribute('src') || '';
if (!originalUrl) {
return;
}
image.dataset.originalUrl = originalUrl;
image.dataset.loadRetries = String(attempts + 1);
window.setTimeout(() => {
if (!image.isConnected) {
return;
}
const separator = originalUrl.includes('?') ? '&' : '?';
image.src = `${originalUrl}${separator}_image_retry=${attempts + 1}`;
}, 500 * (attempts + 1));
}
function handleMessageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
@@ -52,6 +52,10 @@
<div v-else-if="!conversations.conversations.length && !conversations.usingDemoData" class="empty-list">
<strong>暂无会话</strong>
<span>收到或发起消息后会出现在这里</span>
<div class="empty-list-actions">
<button type="button" class="primary-inline-action" @click="openAction('createGroup')">新建群组</button>
<button type="button" class="ghost-inline-action" @click="openAction('joinGroup')">加入群组</button>
</div>
</div>
</div>
+4 -1
View File
@@ -27,7 +27,10 @@ export const appConfig = {
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 300),
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 15000),
sseEnabled: runtimeConfig.sseEnabled ?? process.env.VUE_APP_SSE_ENABLED === 'true',
sseTicketApi: runtimeConfig.sseTicketApi || process.env.VUE_APP_SSE_TICKET_API || '/api/sse_ticket.php',
sseUrl: runtimeConfig.sseUrl || process.env.VUE_APP_SSE_URL || '/ws-events',
platform: 5,
clientIdStorageKey: 'qmt-web-im-client-id',
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
+20 -2
View File
@@ -719,7 +719,16 @@ export class HttpImClient {
throw new Error(response.data?.message || (channel ? '创建公告群失败' : '创建群组失败'));
}
return this.normalizeConversation(response.data?.result?.conversation || {});
const result = response.data?.result || {};
const conversation = this.normalizeConversation(result.conversation || {});
conversation.nativeStatus = result.nativeStatus || '';
conversation.nativeError = result.nativeError || '';
conversation.raw = {
...(conversation.raw || {}),
nativeStatus: conversation.nativeStatus,
nativeError: conversation.nativeError
};
return conversation;
}
async joinGroup(userId, groupKey) {
@@ -733,7 +742,16 @@ export class HttpImClient {
throw new Error(response.data?.message || '加入群组失败');
}
return this.normalizeConversation(response.data?.result?.conversation || {});
const result = response.data?.result || {};
const conversation = this.normalizeConversation(result.conversation || {});
conversation.nativeStatus = result.nativeStatus || '';
conversation.nativeError = result.nativeError || '';
conversation.raw = {
...(conversation.raw || {}),
nativeStatus: conversation.nativeStatus,
nativeError: conversation.nativeError
};
return conversation;
}
async broadcastMessage(userId, targetIds, text) {
+90
View File
@@ -0,0 +1,90 @@
import axios from 'axios';
import { appConfig } from '@/config';
import { imClient } from '@/services/imClient';
class RealtimeNotificationClient {
constructor() {
this.source = null;
this.userId = '';
this.handler = null;
this.reconnectTimer = null;
this.reconnectAttempt = 0;
this.generation = 0;
}
isConnected() {
return this.source?.readyState === WebSocket.OPEN;
}
start(userId, handler) {
this.stop();
if (!appConfig.sseEnabled || !userId || typeof window.WebSocket !== 'function') return;
this.userId = userId;
this.handler = handler;
this.generation += 1;
this.open(this.generation);
}
stop() {
this.generation += 1;
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
if (this.source) this.source.close();
this.source = null;
this.userId = '';
this.handler = null;
this.reconnectAttempt = 0;
}
async open(generation) {
try {
const response = await axios.post(appConfig.sseTicketApi, { userId: this.userId }, {
withCredentials: false,
headers: imClient.getAppAuthHeaders(appConfig.appServer)
});
if (generation !== this.generation) return;
if (response.data?.code !== 0) throw new Error(response.data?.message || '实时通知凭证申请失败');
const ticket = response.data?.result?.ticket || '';
if (!ticket) throw new Error('实时通知凭证为空');
const separator = appConfig.sseUrl.includes('?') ? '&' : '?';
const endpoint = `${appConfig.sseUrl}${separator}ticket=${encodeURIComponent(ticket)}`;
const socketUrl = endpoint.startsWith('ws://') || endpoint.startsWith('wss://')
? endpoint
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
const source = new WebSocket(socketUrl);
this.source = source;
source.onopen = () => {
this.reconnectAttempt = 0;
};
source.onmessage = event => {
if (generation !== this.generation) return;
let payload = {};
try { payload = JSON.parse(event.data || '{}'); } catch (error) { payload = {}; }
if (payload.type !== 'message-notification') return;
this.handler?.(payload);
};
source.onerror = () => {
if (generation !== this.generation) return;
source.close();
if (this.source === source) this.source = null;
this.scheduleReconnect(generation);
};
source.onclose = () => {
if (generation !== this.generation || this.source !== source) return;
this.source = null;
this.scheduleReconnect(generation);
};
} catch (error) {
if (generation === this.generation) this.scheduleReconnect(generation);
}
}
scheduleReconnect(generation) {
window.clearTimeout(this.reconnectTimer);
const delay = Math.min(30000, 1000 * (2 ** Math.min(this.reconnectAttempt, 5)));
this.reconnectAttempt += 1;
this.reconnectTimer = window.setTimeout(() => this.open(generation), delay);
}
}
export const realtimeNotifications = new RealtimeNotificationClient();
+180 -26
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
import { imClient } from '@/services/imClient';
import { appConfig } from '@/config';
import { useSessionStore } from '@/stores/session';
import { realtimeNotifications } from '@/services/realtimeNotifications';
const LOCAL_CONVERSATION_STORAGE_KEY = 'qmt-web-im-local-conversations-v2';
const LOCAL_FAVORITE_MESSAGES_KEY = 'qmt-web-im-favorite-messages-v1';
@@ -70,14 +71,84 @@ function attachmentLabel(message) {
return `[文件] ${attachment.name || '文件'}`;
}
function cleanPlaceholderText(text, message = {}) {
const value = String(text || '').replace(/\s+/g, ' ').trim();
const contentType = Number(message?.contentType || 0);
if (!value) {
return '';
}
if (contentType !== 1 && value === '[暂不支持的消息类型]') {
return '';
}
if ((contentType === 3 || message?.type === 'image') && (value === '[图片]' || value === '图片')) {
return '';
}
return value;
}
function messagePreviewText(message) {
if (isRecalledMessage(message)) {
return `[${message?.recallStatus || '已撤回'}]`;
}
const text = String(message?.text || '').replace(/\s+/g, ' ').trim();
const text = cleanPlaceholderText(message?.text, message);
return text || attachmentLabel(message);
}
function sanitizeConversation(conversation) {
if (!conversation) {
return conversation;
}
return {
...conversation,
subtitle: cleanPlaceholderText(conversation.subtitle, conversation)
};
}
function isHttpUrl(value) {
try {
const url = new URL(String(value || '').trim());
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (error) {
return false;
}
}
function fileNameFromUrl(url, fallback = '图片') {
try {
const path = new URL(url).pathname;
const name = decodeURIComponent(path.split('/').filter(Boolean).pop() || '');
return name || fallback;
} catch (error) {
return fallback;
}
}
function normalizeMessageForDisplay(message) {
if (!message || message.attachment) {
return message;
}
const contentType = Number(message.contentType || 0);
const text = String(message.text || '').trim();
if ((contentType === 3 || message.type === 'image') && isHttpUrl(text)) {
return {
...message,
type: 'image',
text: '',
attachment: {
type: 'image',
url: text,
name: fileNameFromUrl(text, '图片'),
mime: 'image/*',
size: 0
}
};
}
return message;
}
function quotePrefix(message) {
const text = messagePreviewText(message);
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
@@ -138,9 +209,23 @@ function canMarkReadNow(options = {}) {
return true;
}
function isPageVisible() {
return typeof document === 'undefined' || document.visibilityState === 'visible';
}
function activeMessageSyncIntervalMs() {
const configured = Number(appConfig.syncIntervalMs || 0);
return Math.max(15000, Number.isFinite(configured) && configured > 0 ? configured : 15000);
}
function conversationListSyncIntervalMs() {
return Math.max(30000, activeMessageSyncIntervalMs() * 2);
}
function mergeVisibleMessages(currentMessages, syncedMessages) {
const merged = [...syncedMessages];
const merged = [...syncedMessages].map(normalizeMessageForDisplay);
currentMessages.forEach(currentMessage => {
currentMessage = normalizeMessageForDisplay(currentMessage);
const exists = merged.some(message => isSameMessage(message, currentMessage));
const shouldKeepLocal = currentMessage.status === 'sending'
|| currentMessage.status === 'failed'
@@ -230,7 +315,10 @@ export const useConversationStore = defineStore('conversations', {
syncInFlight: false,
pendingReadReceiptConversationId: '',
conversationSyncInFlight: false,
syncAfterSendTimer: null,
messageSyncTimer: null,
messageSyncQueuedAt: 0,
pendingMessageSyncOptions: {},
lastReadSyncByConversation: {},
optimisticMessagesByConversation: {},
quotedMessage: null,
forwardingMessages: [],
@@ -248,7 +336,8 @@ export const useConversationStore = defineStore('conversations', {
favoriteMessages: [],
activeFavoriteKey: '',
actionNotice: '',
actionError: ''
actionError: '',
realtimeSyncTimer: null
}),
getters: {
activeConversation(state) {
@@ -295,9 +384,10 @@ export const useConversationStore = defineStore('conversations', {
}
this.loadScopedLocalState();
this.startMessageSync();
realtimeNotifications.start(session.userId, payload => this.handleRealtimeNotification(payload));
},
loadScopedLocalState() {
this.conversations = readLocalConversations();
this.conversations = readLocalConversations().map(sanitizeConversation);
this.favoriteMessages = readFavoriteMessages();
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
this.activeId = this.conversations[0]?.id || '';
@@ -322,15 +412,21 @@ export const useConversationStore = defineStore('conversations', {
clearUserState() {
window.clearInterval(this.syncTimer);
window.clearInterval(this.conversationSyncTimer);
window.clearTimeout(this.syncAfterSendTimer);
window.clearTimeout(this.messageSyncTimer);
window.clearTimeout(this.attentionMessageTimer);
window.clearTimeout(this.realtimeSyncTimer);
realtimeNotifications.stop();
this.syncTimer = null;
this.conversationSyncTimer = null;
this.syncAfterSendTimer = null;
this.messageSyncTimer = null;
this.messageSyncQueuedAt = 0;
this.pendingMessageSyncOptions = {};
this.attentionMessageTimer = null;
this.realtimeSyncTimer = null;
this.syncInFlight = false;
this.conversationSyncInFlight = false;
this.pendingReadReceiptConversationId = '';
this.lastReadSyncByConversation = {};
this.activeId = '';
this.conversations = [];
this.messagesByConversation = {};
@@ -364,7 +460,7 @@ export const useConversationStore = defineStore('conversations', {
imClient.on('messageReceived', message => {
this.appendMessage(message.conversationId, message);
if (message.conversationId === this.activeId) {
this.syncActiveMessages({ allowReadReceipt: true });
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 700);
}
});
imClient.on('messageStatusChanged', message => {
@@ -390,7 +486,6 @@ export const useConversationStore = defineStore('conversations', {
}
let value = text.trim();
if (this.quotedMessage) {
value = `引用:${quotePrefix(this.quotedMessage)}\n${value}`;
this.quotedMessage = null;
}
const message = {
@@ -411,8 +506,7 @@ export const useConversationStore = defineStore('conversations', {
conversation.subtitle = value;
conversation.updatedAt = Date.now();
writeLocalConversations(this.conversations);
window.clearTimeout(this.syncAfterSendTimer);
this.syncAfterSendTimer = window.setTimeout(() => this.syncActiveMessages(), 250);
this.scheduleActiveMessagesSync({}, 700);
},
sendAttachment({ type, file, text = '' }) {
const conversation = this.activeConversation;
@@ -421,7 +515,6 @@ export const useConversationStore = defineStore('conversations', {
}
let value = text.trim();
if (this.quotedMessage) {
value = `引用:${quotePrefix(this.quotedMessage)}\n${value}`.trim();
this.quotedMessage = null;
}
const now = Date.now();
@@ -451,8 +544,7 @@ export const useConversationStore = defineStore('conversations', {
conversation.subtitle = messagePreviewText(message);
conversation.updatedAt = now;
writeLocalConversations(this.conversations);
window.clearTimeout(this.syncAfterSendTimer);
this.syncAfterSendTimer = window.setTimeout(() => this.syncActiveMessages(), 350);
this.scheduleActiveMessagesSync({}, 700);
},
async sendTextToConversation(conversation, text) {
if (!conversation || !text.trim()) {
@@ -477,6 +569,7 @@ export const useConversationStore = defineStore('conversations', {
return sentMessage;
},
upsertConversation(conversation) {
conversation = sanitizeConversation(conversation);
const index = this.conversations.findIndex(item => item.id === conversation.id);
if (index >= 0) {
this.conversations.splice(index, 1, {
@@ -531,12 +624,13 @@ export const useConversationStore = defineStore('conversations', {
const unread = conversation.id === this.activeId && canMarkReadNow() ? 0 : Number(conversation.unread || 0);
merged.set(conversation.id, {
...(merged.get(conversation.id) || {}),
...conversation,
...sanitizeConversation(conversation),
unread
});
});
this.conversations = Array.from(merged.values())
.map(sanitizeConversation)
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
.slice(0, 100);
@@ -556,6 +650,7 @@ export const useConversationStore = defineStore('conversations', {
this.loadActiveMessages();
},
appendMessage(conversationId, message) {
message = normalizeMessageForDisplay(message);
if (!this.messagesByConversation[conversationId]) {
this.messagesByConversation = {
...this.messagesByConversation,
@@ -615,7 +710,7 @@ export const useConversationStore = defineStore('conversations', {
return;
}
conversation.subtitle = message.text || conversation.subtitle || '';
conversation.subtitle = messagePreviewText(message) || cleanPlaceholderText(conversation.subtitle, conversation);
conversation.updatedAt = messageActivityTimestamp(message) || Date.now();
if (message.direction === 'in') {
conversation.unread = conversationId === this.activeId ? 0 : Number(conversation.unread || 0) + 1;
@@ -624,6 +719,7 @@ export const useConversationStore = defineStore('conversations', {
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
},
appendVisibleMessage(conversationId, message) {
message = normalizeMessageForDisplay(message);
const messages = this.visibleMessagesByConversation[conversationId] || [];
this.visibleMessagesByConversation = {
...this.visibleMessagesByConversation,
@@ -632,6 +728,7 @@ export const useConversationStore = defineStore('conversations', {
};
},
replaceMessage(conversationId, message) {
message = normalizeMessageForDisplay(message);
if (!this.messagesByConversation[conversationId]) {
this.messagesByConversation = {
...this.messagesByConversation,
@@ -729,9 +826,14 @@ export const useConversationStore = defineStore('conversations', {
if (!conversation) {
return;
}
if (options.skipWhenHidden !== false && !isPageVisible()) {
return;
}
const wantsReadReceipt = Boolean(options.allowReadReceipt);
const explicitReadReceipt = wantsReadReceipt && canMarkReadNow({ userAction: Boolean(options.userAction) });
const pollingReadReceipt = canMarkReadNow();
const now = Date.now();
const previousReadSyncAt = Number(this.lastReadSyncByConversation[conversation.id] || 0);
const pollingReadReceipt = canMarkReadNow() && now - previousReadSyncAt >= 10000;
const markRead = explicitReadReceipt || pollingReadReceipt;
if (this.syncInFlight) {
@@ -747,6 +849,12 @@ export const useConversationStore = defineStore('conversations', {
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
markRead
});
if (markRead) {
this.lastReadSyncByConversation = {
...this.lastReadSyncByConversation,
[syncedConversationId]: Date.now()
};
}
const editedIncomingMessages = incomingEditedMessages(previousMessages, messages);
this.pruneOptimisticMessages(syncedConversationId, messages);
this.messagesByConversation = {
@@ -775,24 +883,63 @@ export const useConversationStore = defineStore('conversations', {
this.syncInFlight = false;
if (this.pendingReadReceiptConversationId === this.activeId && canMarkReadNow()) {
this.pendingReadReceiptConversationId = '';
window.setTimeout(() => this.syncActiveMessages({ allowReadReceipt: true }), 0);
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 700);
}
}
},
scheduleActiveMessagesSync(options = {}, delay = 700) {
if (!this.activeId || !isPageVisible()) {
return;
}
const now = Date.now();
if (!this.messageSyncQueuedAt) {
this.messageSyncQueuedAt = now;
}
this.pendingMessageSyncOptions = {
...this.pendingMessageSyncOptions,
allowReadReceipt: Boolean(
this.pendingMessageSyncOptions.allowReadReceipt || options.allowReadReceipt
),
userAction: Boolean(this.pendingMessageSyncOptions.userAction || options.userAction)
};
const maxWait = 1200;
const runAt = Math.min(now + Math.max(0, Number(delay) || 0), this.messageSyncQueuedAt + maxWait);
window.clearTimeout(this.messageSyncTimer);
this.messageSyncTimer = window.setTimeout(() => {
const pendingOptions = this.pendingMessageSyncOptions;
this.messageSyncTimer = null;
this.messageSyncQueuedAt = 0;
this.pendingMessageSyncOptions = {};
this.syncActiveMessages(pendingOptions);
}, Math.max(0, runAt - now));
},
startMessageSync() {
this.bindReadPresenceEvents();
if (!this.syncTimer) {
this.syncTimer = window.setInterval(() => {
this.syncActiveMessages();
}, Math.max(250, appConfig.syncIntervalMs));
if (isPageVisible() && !realtimeNotifications.isConnected()) {
this.syncActiveMessages();
}
}, activeMessageSyncIntervalMs());
}
if (!this.conversationSyncTimer) {
this.conversationSyncTimer = window.setInterval(() => {
this.refreshRemoteConversations();
}, 2000);
if (isPageVisible() && !realtimeNotifications.isConnected()) {
this.refreshRemoteConversations();
}
}, conversationListSyncIntervalMs());
this.refreshRemoteConversations();
}
},
handleRealtimeNotification() {
window.clearTimeout(this.realtimeSyncTimer);
this.realtimeSyncTimer = window.setTimeout(() => {
this.refreshRemoteConversations();
if (this.activeId) {
this.scheduleActiveMessagesSync({ allowReadReceipt: canMarkReadNow() }, 700);
}
}, 500);
},
bindReadPresenceEvents() {
if (this.readPresenceEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
return;
@@ -800,7 +947,8 @@ export const useConversationStore = defineStore('conversations', {
this.readPresenceEventsBound = true;
const syncWhenReadable = () => {
if (canMarkReadNow()) {
this.syncActiveMessages({ allowReadReceipt: true });
this.refreshRemoteConversations();
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 300);
}
};
window.addEventListener('focus', syncWhenReadable);
@@ -1084,7 +1232,7 @@ export const useConversationStore = defineStore('conversations', {
const editedMessage = await imClient.editMessage(conversation, message, text);
this.replaceLocalMessage(conversation.id, message, editedMessage);
if (messageActivityTimestamp(editedMessage) >= Number(conversation.updatedAt || 0)) {
conversation.subtitle = editedMessage.text;
conversation.subtitle = messagePreviewText(editedMessage);
conversation.updatedAt = messageActivityTimestamp(editedMessage);
writeLocalConversations(this.conversations);
}
@@ -1145,7 +1293,12 @@ export const useConversationStore = defineStore('conversations', {
const conversation = await imClient.createGroup(session.userId, options);
this.upsertConversation(conversation);
this.setActiveConversation(conversation.id);
this.setActionNotice(options.channel ? '公告群已创建' : '群组已创建');
const nativeStatus = conversation.nativeStatus || conversation.raw?.nativeStatus || '';
if (nativeStatus && nativeStatus !== 'ok') {
this.setActionNotice(options.channel ? '公告群已创建,仅网页可见' : '群组已创建,仅网页可见');
} else {
this.setActionNotice(options.channel ? '公告群已创建' : '群组已创建');
}
this.refreshRemoteConversations();
return true;
} catch (error) {
@@ -1163,7 +1316,8 @@ export const useConversationStore = defineStore('conversations', {
const conversation = await imClient.joinGroup(session.userId, groupKey);
this.upsertConversation(conversation);
this.setActiveConversation(conversation.id);
this.setActionNotice('已加入群组');
const nativeStatus = conversation.nativeStatus || conversation.raw?.nativeStatus || '';
this.setActionNotice(nativeStatus && nativeStatus !== 'ok' ? '已加入群组,仅网页可见' : '已加入群组');
this.refreshRemoteConversations();
return true;
} catch (error) {
+124 -4
View File
@@ -1238,6 +1238,14 @@ button {
padding: 24px;
}
.empty-list-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.empty-chat {
min-height: 0;
padding: 24px;
@@ -1898,6 +1906,16 @@ button {
font-size: 12px;
}
.group-member-row .group-member-role {
width: fit-content;
padding: 2px 7px;
border: 1px solid #b8d8d1;
border-radius: 5px;
background: #edf8f5;
color: var(--primary);
font-weight: 700;
}
.group-management-dialog {
width: min(520px, calc(100% - 32px));
}
@@ -1958,7 +1976,9 @@ button {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 24px;
padding: 24px 24px 72px;
scroll-padding-bottom: 72px;
scrollbar-gutter: stable;
}
.message-row {
@@ -2769,9 +2789,11 @@ button {
}
.composer-tools button {
width: 32px;
height: 32px;
border: 0;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--muted);
@@ -2782,6 +2804,104 @@ button {
background: #edf2f5;
}
.composer-tools button:disabled {
cursor: default;
opacity: 0.45;
}
.composer-attachment-preview {
display: flex;
gap: 8px;
padding: 10px 16px 0;
overflow-x: auto;
}
.composer-attachment-item {
position: relative;
width: 88px;
min-width: 88px;
height: 88px;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: #edf2f5;
}
.composer-attachment-item img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.composer-attachment-file-icon {
color: var(--primary);
font-size: 30px;
}
.composer-attachment-name {
position: absolute;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
padding: 4px 6px;
background: rgba(18, 28, 40, 0.72);
color: #fff;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer-attachment-item > button {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(18, 28, 40, 0.74);
color: #fff;
font-size: 17px;
line-height: 1;
}
.composer-tool-button {
border-color: rgba(23, 125, 114, 0.28);
background: rgba(23, 125, 114, 0.1);
color: var(--primary);
}
.composer-tool-button.file-tool {
border-color: rgba(216, 78, 63, 0.28);
background: rgba(216, 78, 63, 0.1);
color: var(--accent);
}
.composer-tool-button:hover {
border-color: currentColor;
background: rgba(23, 125, 114, 0.16);
}
.composer-tool-button.file-tool:hover {
background: rgba(216, 78, 63, 0.16);
}
.composer-tool-icon {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.emoji-picker {
position: absolute;
left: 16px;