Обновить DM, сеть и оффлайн-бандл

This commit is contained in:
AidarKC
2026-09-03 12:26:50 +04:00
parent aff601f61a
commit e7c8fd748c
8 changed files with 403 additions and 60 deletions
@@ -77,16 +77,18 @@ public final class DmDialogStateDAO {
ps.setString(1, cleanOwner);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
int unreadCount = rs.getInt("unread_count");
DialogSummary row = new DialogSummary(
rs.getString("owner_login"),
rs.getString("peer_login"),
normalizeRelationFlag(rs.getString("relation_flag")),
rs.getString("last_message_blob_b64"),
rs.getLong("last_message_time_ms"),
rs.getInt("unread_count"),
lastMessageTimeMs,
unreadCount,
rs.getLong("last_read_receipt_time_ms"),
rs.getLong("updated_at_ms"),
true
lastMessageTimeMs > 0 || unreadCount > 0
);
byPeer.put(normKey(row.peerLogin()), row);
}
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Build an offline-ready source bundle ZIP.
# In addition to the normal source tree, this variant can attach a local
# Gradle distribution zip and a helper script that rewrites wrapper URLs to
# that local file so the bundle can be used without internet access.
#
# Usage:
# ./bundle-offline.sh
# ./bundle-offline.sh path/to/output.zip
#
# Expected local asset:
# offline/gradle-offline.zip
# or a custom path via BUNDLE_OFFLINE_GRADLE_ZIP
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
OUT="${1:-SHiNE-bundle-offline-$(date +%Y%m%d-%H%M%S).zip}"
case "$OUT" in
/*) ;;
*) OUT="$ROOT/$OUT" ;;
esac
if ! command -v zip >/dev/null 2>&1; then
echo "ERROR: 'zip' is required." >&2
exit 1
fi
TMP="$(mktemp -d)"
LIST="$TMP/files.txt"
SAFE_LIST="$TMP/safe-files.txt"
STAGE="$TMP/stage"
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$STAGE"
# Paths / filenames that must never be bundled.
is_denied_path() {
local p="/$1"
case "$p" in
*/.git/*|*/.git|\
*/.gradle/*|*/.gradle|\
*/.gradle-home/*|*/.gradle-home|\
*/.idea/*|*/.idea|\
*/.vscode/*|*/.vscode|\
*/node_modules/*|*/node_modules|\
*/target/*|*/target|\
*/build/*|*/build|\
*/out/*|*/out|\
*/bin/*|*/bin|\
*/logs/*|*/logs|\
*/data/*|*/data|\
*/test-ledger/*|*/test-ledger|\
*/.anchor/*|*/.anchor|\
*/.yarn/*|*/.yarn|\
*/.vendor/*|*/.vendor|\
*/.agents/*|*/.agents|\
*/.codex/*|*/.codex|\
*/.claude/*|*/.claude|\
*/deploy/backup/archive/*|\
*/scripts/*/runs/*|\
*/scripts/*/keypairs/*|\
*/keys/*|\
*/.git-local-backup/*|\
*/SHiNE-bundle-*.zip|\
*/bundle-offline*.zip)
return 0
;;
esac
local base="${p##*/}"
local lower
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
case "$lower" in
.env|.env.*|\
.debug-token|\
.npmrc|.pypirc|.netrc|\
credentials|credentials.*|\
secrets|secrets.*|\
secret|secret.*|\
id_rsa|id_dsa|id_ecdsa|id_ed25519|\
*.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\
*keypair*.json|\
service-account*.json|\
firebase-adminsdk*.json|\
google-services.json|\
validator.log)
return 0
;;
esac
case "$lower" in
*.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\
*.elf|*.map|*.uf2|*.bin|*.merged.bin|\
*.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\
.ds_store)
return 0
;;
esac
return 1
}
find_offline_gradle_zip() {
local candidate="${BUNDLE_OFFLINE_GRADLE_ZIP:-}"
if [[ -n "$candidate" && -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
for candidate in \
"$ROOT/offline/gradle-offline.zip" \
"$ROOT/offline/gradle-8.14-bin.zip" \
"$ROOT/offline/gradle.zip"
do
if [[ -f "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
create_offline_helper() {
local zip_name="$1"
local helper="$STAGE/offline/prepare-local-gradle.sh"
local readme="$STAGE/offline/README.txt"
mkdir -p "$STAGE/offline"
cat > "$helper" <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
ZIP_PATH="\${1:-\$ROOT/offline/$zip_name}"
if [[ ! -f "\$ZIP_PATH" ]]; then
echo "ERROR: offline Gradle zip not found: \$ZIP_PATH" >&2
exit 1
fi
ABS_ZIP="\$(cd -- "\$(dirname -- "\$ZIP_PATH")" && pwd -P)/\$(basename -- "\$ZIP_PATH")"
ESCAPED_ABS_ZIP="\${ABS_ZIP//\\\\/\\\\\\\\}"
ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//&/\\\\&}"
ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//|/\\\\|}"
while IFS= read -r props; do
[[ -f "\$props" ]] || continue
cp -p "\$props" "\$props.bak"
sed -i -e "s|^distributionUrl=.*\$|distributionUrl=file://\$ESCAPED_ABS_ZIP|" "\$props"
done < <(find "\$ROOT" -path '*/gradle/wrapper/gradle-wrapper.properties' -type f | sort)
cat <<'MSG'
Gradle wrapper URLs rewritten to the local offline zip.
Run now:
./gradlew --offline test
MSG
EOF
chmod +x "$helper"
cat > "$readme" <<EOF
Offline Gradle helper
Included archive:
offline/$zip_name
Helper:
offline/prepare-local-gradle.sh
What it does:
- backs up each gradle-wrapper.properties as .bak
- rewrites wrapper distributionUrl to the local zip in this bundle
Recommended flow after unpacking:
1. cd into the unpacked bundle root
2. run ./offline/prepare-local-gradle.sh
3. run ./gradlew --offline test
This bundle is intended for local, network-free verification.
EOF
}
# Collect files. Prefer Git because it naturally avoids most ignored local files.
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z"
else
find "$ROOT" -type f -print0 > "$TMP/files.z"
fi
# Convert to project-relative paths and enforce hard deny rules.
: > "$LIST"
while IFS= read -r -d '' f; do
if [[ "$f" = /* ]]; then
rel="${f#"$ROOT"/}"
else
rel="$f"
fi
[[ "$rel" == "$OUT" ]] && continue
[[ -z "$rel" ]] && continue
if is_denied_path "$rel"; then
continue
fi
printf '%s\n' "$rel" >> "$LIST"
done < "$TMP/files.z"
sort -u "$LIST" -o "$LIST"
# Always include Gradle wrapper bootstrap, even though generic JARs are denied.
for wrapper_jar in \
'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' \
'SHiNE-browser-plugin-wallet/gradle/wrapper/gradle-wrapper.jar'
do
if [[ -f "$ROOT/$wrapper_jar" ]] && ! grep -Fxq "$wrapper_jar" "$LIST"; then
printf '%s\n' "$wrapper_jar" >> "$LIST"
fi
done
sort -u "$LIST" -o "$LIST"
# Content scan: fail closed on common credential/private-key patterns.
# We scan only text-ish files; grep -I skips binary data.
SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}'
: > "$SAFE_LIST"
found_secret=0
while IFS= read -r rel; do
[[ -f "$ROOT/$rel" ]] || continue
# Files that contain examples/templates can legitimately mention secret keys
# with placeholders. They are scanned too, but placeholder-looking values
# are less likely to match the regex above.
if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then
echo "BLOCKED: possible secret in $rel" >&2
LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \
| sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \
| head -n 3 >&2 || true
found_secret=1
continue
fi
printf '%s\n' "$rel" >> "$SAFE_LIST"
done < "$LIST"
if (( found_secret != 0 )); then
echo >&2
echo "Bundle NOT created because possible secrets were detected." >&2
echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2
exit 2
fi
if [[ ! -s "$SAFE_LIST" ]]; then
echo "ERROR: no files left to bundle." >&2
exit 3
fi
OFFLINE_ZIP_SRC=""
OFFLINE_ZIP_NAME=""
if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then
OFFLINE_ZIP_NAME="gradle-offline.zip"
else
echo "ERROR: offline Gradle zip not found." >&2
echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2
echo "The bundle is not created because this variant is meant to be offline-ready." >&2
exit 4
fi
rm -rf "$STAGE"
mkdir -p "$STAGE"
while IFS= read -r rel; do
src="$ROOT/$rel"
dst="$STAGE/$rel"
mkdir -p "$(dirname -- "$dst")"
cp -p "$src" "$dst"
done < "$SAFE_LIST"
mkdir -p "$STAGE/offline"
cp -p "$OFFLINE_ZIP_SRC" "$STAGE/offline/$OFFLINE_ZIP_NAME"
create_offline_helper "$OFFLINE_ZIP_NAME"
rm -f -- "$OUT"
(
cd "$STAGE"
find . -type f -print | sort | zip -q -9 "$OUT" -@
)
echo "Created: $OUT"
echo "Files: $(cd "$STAGE" && find . -type f | wc -l | tr -d ' ')"
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
@@ -203,3 +203,15 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
## UI: видимость пустого диалога после DeleteConversation
`DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога.
Для списка личных чатов действует правило:
- если после очистки истории у пары нет обычных DM-сообщений и пользователь не находится в `contact`, `friend` или `close_friend`, строка диалога не показывается;
- если связь `contact`, `friend` или `close_friend` сохраняется, пустой чат может оставаться в списке как чат существующей связи;
- при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю.
Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone.
@@ -356,3 +356,9 @@ ReadReceiptBody_v1_0
## Примечание UI списка чатов (2026-08-28)
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
## UI-семантика `type=7/8` в списке диалогов
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется.
+47 -17
View File
@@ -264,18 +264,30 @@ function openChatConfirmModal({
});
}
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', onConfirm }) {
const root = document.getElementById('modal-root');
if (!root) return;
const relation = normalizeChatRelationType(relationType);
const isCloseFriend = relation === 'close_friend';
const isFriend = relation === 'friend';
const isProtectedRelation = isCloseFriend || isFriend;
const relationName = isCloseFriend ? 'близких друзей' : 'друзей';
const safeName = String(contactName || '').trim() || 'этого пользователя';
root.innerHTML = `
<div class="modal" id="chat-delete-chat-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Удалить чат?</h3>
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
<label class="dm-confirm-check">
<input type="checkbox" id="chat-delete-chat-history" checked />
<span>Также удалить всю историю переписки</span>
</label>
${isProtectedRelation ? `
<p class="meta-muted">Можно удалить содержимое переписки, но чат с ${isCloseFriend ? 'близким другом' : 'другом'} останется в списке.</p>
<p class="meta-muted">Удалить ${safeName} из ${relationName} и удалить чат?</p>
` : `
<p class="meta-muted">Удалить пользователя ${safeName} из контактов?</p>
<label class="dm-confirm-check">
<input type="checkbox" id="chat-delete-chat-history" checked />
<span>Также удалить всю историю переписки</span>
</label>
`}
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
@@ -290,10 +302,12 @@ function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
const deleteHistory = isProtectedRelation
? true
: Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
close();
if (typeof onConfirm === 'function') {
await onConfirm({ deleteHistory });
await onConfirm({ deleteHistory, removeRelation: isProtectedRelation });
}
});
}
@@ -1173,24 +1187,40 @@ export function render({ navigate, route, chrome }) {
});
},
onDeleteChat: async () => {
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
openDeleteChatConfirmModal({
contactName: contact.name,
relationType: relationBeforeDelete,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) {
await clearConversationHistory();
}
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
// Друг/близкий друг может иметь одновременно и более слабые флаги связи.
// Для настоящего «удалить чат» снимаем весь социальный стек, иначе пустой чат
// закономерно останется в списке из-за действующей связи.
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
? ['close_friend', 'friend', 'contact']
: ['contact'];
for (const kind of relationKinds) {
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || []);
setContacts(
contactsPayload?.contacts
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|| [],
);
notifyUnreadStateUpdated();
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
showToast('Чат удалён', { timeoutMs: 1200 });
navigate('messages-list');
} catch (error) {
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
+3
View File
@@ -448,6 +448,9 @@ function renderRow(item) {
});
const rows = Array.from(byPeer.values())
// Технический tombstone очистки истории сам по себе не создаёт видимый диалог.
// Пустые друзья/контакты остаются, а пользователь без связи исчезает после очистки.
.filter((item) => normalizeRelationFlag(item.relationFlag) !== 'none' || Boolean(item.hasDialog))
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
.sort((a, b) => {
const orderA = relationOrder(a.relationFlag);
+23 -32
View File
@@ -1,4 +1,6 @@
import { renderHeader } from '../components/header.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
@@ -25,32 +27,6 @@ function createDebounced(fn, delayMs = 2000) {
};
}
function createHeaderSearchIcon() {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', 'header-icon-svg header-icon-svg--search');
const circle = document.createElementNS(ns, 'circle');
circle.setAttribute('cx', '11');
circle.setAttribute('cy', '11');
circle.setAttribute('r', '6.5');
circle.setAttribute('fill', 'none');
circle.setAttribute('stroke', 'currentColor');
circle.setAttribute('stroke-width', '2');
const handle = document.createElementNS(ns, 'path');
handle.setAttribute('d', 'M16 16l4.5 4.5');
handle.setAttribute('fill', 'none');
handle.setAttribute('stroke', 'currentColor');
handle.setAttribute('stroke-width', '2');
handle.setAttribute('stroke-linecap', 'round');
svg.append(circle, handle);
return svg;
}
function normKey(value) {
return normalizeLogin(value).toLowerCase();
}
@@ -290,7 +266,7 @@ export function render({ navigate, route, chrome } = {}) {
<div class="modal" id="network-search-modal">
<div class="modal-card stack">
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;"></button>
<h3 class="modal-title">Найти человека</h3>
<h3 class="modal-title">Найти пользователя</h3>
<div class="row" style="gap:8px;">
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
@@ -462,17 +438,32 @@ export function render({ navigate, route, chrome } = {}) {
title: 'Связи',
rightActions: [
{
iconNode: createHeaderSearchIcon(),
title: 'Найти пользователя',
ariaLabel: 'Найти пользователя',
className: 'chat-header-icon-btn',
onClick: openSearchModal,
iconNode: createOverflowDots(),
title: 'Меню связей',
ariaLabel: 'Открыть меню связей',
className: 'chat-header-icon-btn network-header-menu-btn',
onClick: () => {},
},
],
});
const networkMenuButton = header.querySelector('.network-header-menu-btn');
const searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const networkMenu = createDropdownMenu({
anchorEl: networkMenuButton,
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
],
});
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => {
networkMenu.destroy();
if (engine) engine.destroy();
engine = null;
appScreenEl?.classList.remove('network-scroll-lock');
+7 -8
View File
@@ -242,10 +242,9 @@
.fg-node.is-pressed .node-dot { transform: none; }
}
/* «Сияние» мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
Многослойная анимированная box-shadow + размытый радиальный ореол (через внешний SVG-фильтр).
Пульсация очень медленная и плавная (3.6с): радиус и прозрачность «дышат» 0.5 1.0
как мягкое свечение живого организма в темноте, а не «жирный маркер». */
/* «Сияние» постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль:
визуально сияющий пользователь всегда остаётся явно сияющим. */
.fg-node.is-shine .node-dot {
border-color: rgba(150, 240, 255, 0.62);
animation: fg-shine-glow 3.6s ease-in-out infinite;
@@ -268,9 +267,9 @@
@keyframes fg-shine-glow {
0%, 100% {
box-shadow:
0 0 5px rgba(125, 232, 255, 0.30),
0 0 11px rgba(112, 226, 255, 0.18),
0 0 20px rgba(100, 220, 255, 0.10);
0 0 7px rgba(138, 239, 255, 0.48),
0 0 15px rgba(118, 232, 255, 0.32),
0 0 27px rgba(100, 220, 255, 0.19);
}
50% {
box-shadow:
@@ -282,7 +281,7 @@
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
@keyframes fg-shine-halo {
0%, 100% { transform: scale(0.9); opacity: 0.5; }
0%, 100% { transform: scale(0.98); opacity: 0.72; }
50% { transform: scale(1.12); opacity: 1; }
}