Добавить opinion-связи и обновить UI связей в профиле

This commit is contained in:
AidarKC 2026-05-20 13:13:50 +03:00
parent a53444b863
commit aa35d87885
18 changed files with 452 additions and 49 deletions

View File

@ -15,6 +15,14 @@
- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение. - Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
- Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён. - Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён.
- Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять). - Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять).
- Добавление любых данных в блокчейн выполнять только через операцию `AddBlock`.
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
## Документация личных сообщений (DM)
- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/README.md`.
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять `Dev_Docs/Personal_Messages/README.md`.
- Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`.
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
## Версионирование ## Версионирование
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория). - Единый файл версий проекта: `VERSION.properties` (в корне репозитория).

View File

@ -107,6 +107,20 @@
- `CONNECTION_UNCONTACT (21)` - `CONNECTION_UNCONTACT (21)`
- `CONNECTION_FOLLOW (30)` - `CONNECTION_FOLLOW (30)`
- `CONNECTION_UNFOLLOW (31)` - `CONNECTION_UNFOLLOW (31)`
- `CONNECTION_SPOUSE (40)`
- `CONNECTION_UNSPOUSE (41)`
- `CONNECTION_PARENT (50)`
- `CONNECTION_UNPARENT (51)`
- `CONNECTION_CHILD (52)`
- `CONNECTION_UNCHILD (53)`
- `CONNECTION_SIBLING (54)`
- `CONNECTION_UNSIBLING (55)`
- `CONNECTION_KNOWN_PERSON (60)`
- `CONNECTION_UNKNOWN_PERSON (61)`
- `CONNECTION_SHINE_CONFIRMED (70)`
- `CONNECTION_SHINE_UNCONFIRMED (71)`
- `CONNECTION_SHINE_SEEN (74)`
- `CONNECTION_SHINE_UNSEEN (75)`
5. **USER_PARAM (type=4)** 5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)` - `USER_PARAM_TEXT_TEXT (1)`

View File

@ -4,20 +4,16 @@ CONNECTION-тип описывает социальные связи и подп
## Подтипы ## Подтипы
1. `subType=10``CONNECTION_FRIEND` `10/11``close_friend / unclose_friend` (близкий друг)
2. `subType=11``CONNECTION_UNFRIEND` `20/21``contact / uncontact` (контакт)
3. `subType=20``CONNECTION_CONTACT` `30/31``follow / unfollow` (подписан)
4. `subType=21``CONNECTION_UNCONTACT` `40/41``spouse / unspouse` (супруг/супруга)
5. `subType=30``CONNECTION_FOLLOW` `50/51``parent / unparent` (родитель)
6. `subType=31``CONNECTION_UNFOLLOW` `52/53``child / unchild` (ребёнок)
7. `subType=40``CONNECTION_SPOUSE` `54/55``sibling / unsibling` (брат/сестра)
8. `subType=41``CONNECTION_UNSPOUSE` `60/61``known_person / unknown_person` (знаю этого человека)
9. `subType=50``CONNECTION_PARENT` `70/71``shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий)
10. `subType=51``CONNECTION_UNPARENT` `74/75``shine_seen / shine_unseen` (мало знаком, но видел сияющим)
11. `subType=52``CONNECTION_CHILD`
12. `subType=53``CONNECTION_UNCHILD`
13. `subType=54``CONNECTION_SIBLING`
14. `subType=55``CONNECTION_UNSIBLING`
## Общий формат payload ## Общий формат payload

View File

@ -1,5 +1,13 @@
# История изменений документации блокчейна # История изменений документации блокчейна
## 2026-05-20 11:34:17 +0300
- Базовый коммит-ориентир: `a53444b`.
- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы:
- `60/61``known_person / unknown_person` (знаю этого человека);
- `70/71``shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий);
- `74/75``shine_seen / shine_unseen` (мало знаком, но видел сияющим).
- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
## 2026-05-19 20:30:21 +0300 ## 2026-05-19 20:30:21 +0300
- Базовый коммит-ориентир: `7986184`. - Базовый коммит-ориентир: `7986184`.
- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения. - Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения.

View File

@ -0,0 +1,23 @@
## Краткое описание
Добавлены новые типы connection-связей в блокчейне и API:
- `known_person` (`60/61`)
- `shine_confirmed` (`70/71`)
- `shine_seen` (`74/75`)
## Что проверять
1. `AddBlock` принимает новые `msg_sub_type` для `type=3`.
2. Связи корректно попадают в `connections_state`:
- ON создаёт/обновляет запись;
- OFF удаляет запись соответствующего ON-типа.
3. `GetUserConnectionsGraph` возвращает новые поля:
- `outKnownPersons`, `inKnownPersons`
- `outShineConfirmed`, `inShineConfirmed`
- `outShineSeen`, `inShineSeen`
4. Клиент `setUserRelation` принимает `kind`:
- `known_person`, `shine_confirmed`, `shine_seen`.
## Ожидаемый результат
Новые связи работают как обычные ON/OFF relation-типы, но не ломают текущие friend/contact/follow и остальные существующие связи.
## Статус
`pending`

View File

@ -0,0 +1,25 @@
## Краткое описание
Перестроен блок связей в профиле чужого пользователя и добавлен UI для одностороннего "мнения" (`known_person` / `shine_confirmed` / `shine_seen`) с взаимным исключением на уровне UI.
## Что проверять
1. Порядок базовых строк в профиле:
- Контакт
- Близкий друг
- Подписка
2. Под этими строками отображается блок мнений:
- при отсутствии мнения кнопка `Добавить связь`;
- при наличии мнения кнопка `Изменить связи`;
- показываются текстовые формулировки для активного мнения.
3. В модальном меню:
- варианты добавления (синие);
- `Убрать мнение` (красная).
4. При смене мнения отправляется последовательность:
- OFF старой связи,
- ON новой связи.
5. Для новых мнений показываются только исходящие (`out*`) оценки текущего пользователя (односторонняя логика).
## Ожидаемый результат
Пользователь управляет одним активным мнением через UI, состояние читается корректно и не ломает существующие friend/contact/follow кнопки.
## Статус
`pending`

View File

@ -1,2 +1,2 @@
client.version=1.2.78 client.version=1.2.79
server.version=1.2.72 server.version=1.2.73

View File

@ -28,28 +28,28 @@ function genderText(value) {
} }
function relationButtonLabel(kind, flags) { function relationButtonLabel(kind, flags) {
if (kind === 'follow') return flags.outFollow ? 'Отписаться' : 'Подписаться'; if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья'; if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты'; return flags.outFollow ? 'Отписаться' : 'Подписаться';
} }
function relationNextState(kind, flags) { function relationNextState(kind, flags) {
if (kind === 'follow') return !flags.outFollow; if (kind === 'contact') return !flags.outContact;
if (kind === 'friend') return !flags.outFriend; if (kind === 'friend') return !flags.outFriend;
return !flags.outContact; return !flags.outFollow;
} }
function relationConfirmLabel(kind) { function relationConfirmLabel(kind) {
if (kind === 'follow') return 'подписку'; if (kind === 'contact') return 'контакт';
if (kind === 'friend') return 'статус близкого друга'; if (kind === 'friend') return 'статус близкого друга';
return 'контакт'; return 'подписку';
} }
function relationStateText(kind, flags) { function relationStateText(kind, flags) {
if (kind === 'follow') { if (kind === 'contact') {
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.'; if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
if (flags.outFollow) return 'Вы подписаны на этот профиль.'; if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
if (flags.inFollow) return 'Этот профиль подписан на вас.'; if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
return ''; return '';
} }
if (kind === 'friend') { if (kind === 'friend') {
@ -58,12 +58,52 @@ function relationStateText(kind, flags) {
if (flags.inFriend) return 'Этот профиль считает вас близким другом.'; if (flags.inFriend) return 'Этот профиль считает вас близким другом.';
return ''; return '';
} }
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.'; if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
if (flags.outContact) return 'Вы добавили этот профиль в контакты.'; if (flags.outFollow) return 'Вы подписаны на этот профиль.';
if (flags.inContact) return 'Этот профиль добавил вас в контакты.'; if (flags.inFollow) return 'Этот профиль подписан на вас.';
return ''; return '';
} }
function opinionItemsFromFlags(flags) {
const items = [];
if (flags.outShineSeen) {
items.push({
kind: 'shine_seen',
text: 'вы утверждаете, что очень мало знаете этого человека, но вы видели его сияющим, и всё, что вы о нём знаете, подтверждает это',
label: 'видел сияющим',
});
}
if (flags.outShineConfirmed) {
items.push({
kind: 'shine_confirmed',
text: 'вы утверждаете, что достаточно хорошо знаете этого человека и точно уверены, что этот человек сияющий',
label: 'точно сияющий',
});
}
if (flags.outKnownPerson) {
items.push({
kind: 'known_person',
text: 'вы утверждаете, что просто знаете этого человека',
label: 'просто знаю',
});
}
return items;
}
function resolveActiveOpinionKind(flags) {
if (flags.outShineSeen) return 'shine_seen';
if (flags.outShineConfirmed) return 'shine_confirmed';
if (flags.outKnownPerson) return 'known_person';
return '';
}
function opinionLabelByKind(kind) {
if (kind === 'shine_seen') return 'мало знаком, но видел сияющим';
if (kind === 'shine_confirmed') return 'точно уверен, что сияющий';
if (kind === 'known_person') return 'просто знаю человека';
return kind;
}
function renderIdentity(card) { function renderIdentity(card) {
const lines = buildIdentityLines({ const lines = buildIdentityLines({
login: card.login, login: card.login,
@ -109,10 +149,12 @@ function renderReadOnlyBadges(card) {
function renderRelations(flags) { function renderRelations(flags) {
const rows = [ const rows = [
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) }, { kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
]; ];
const opinionItems = opinionItemsFromFlags(flags);
const hasOpinion = opinionItems.length > 0;
return ` return `
<div class="card stack user-relations-list"> <div class="card stack user-relations-list">
@ -122,10 +164,65 @@ function renderRelations(flags) {
<button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button> <button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button>
</div> </div>
`).join('')} `).join('')}
<div class="user-rel-opinions-wrap ${hasOpinion ? '' : 'is-empty'}">
<div class="user-rel-opinions-list">
${opinionItems.map((item) => `
<div class="user-rel-opinion-item">${escapeHtml(item.text)}</div>
`).join('')}
</div>
<div class="user-rel-opinions-hint">Добавьте одну из этих трёх формулировок.</div>
</div>
<div class="user-rel-row">
<span class="user-rel-text">${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}</span>
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить связи' : 'Добавить связь'}</button>
</div>
</div> </div>
`; `;
} }
function openOpinionMenuModal({ flags, onApply }) {
const root = document.getElementById('modal-root');
if (!root) return;
const activeKind = resolveActiveOpinionKind(flags);
const items = [
{ kind: 'known_person', title: 'просто знаю человека' },
{ kind: 'shine_confirmed', title: 'точно уверен, что сияющий' },
{ kind: 'shine_seen', title: 'мало знаком, но видел сияющим' },
];
const rowsHtml = items
.filter((item) => item.kind !== activeKind)
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Добавить: ${item.title}</button>`)
.join('');
const removeHtml = activeKind
? `<button class="secondary-btn user-opinion-modal-btn is-remove" type="button" data-opinion-kind="${activeKind}" data-opinion-mode="remove">Убрать мнение</button>`
: '';
root.innerHTML = `
<div class="modal" id="user-opinion-modal">
<div class="modal-card stack">
<h3 class="modal-title">${activeKind ? 'Изменить связи' : 'Добавить связь'}</h3>
<div class="stack">${rowsHtml}${removeHtml}</div>
<button class="secondary-btn" type="button" id="user-opinion-modal-close">Закрыть</button>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#user-opinion-modal-close')?.addEventListener('click', close);
root.querySelector('#user-opinion-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'user-opinion-modal') close();
});
root.querySelectorAll('[data-opinion-mode]').forEach((btn) => {
btn.addEventListener('click', async () => {
const nextKind = String(btn.getAttribute('data-opinion-kind') || '').trim();
const mode = String(btn.getAttribute('data-opinion-mode') || '').trim();
close();
if (!nextKind) return;
await onApply({ mode, nextKind, activeKind });
});
});
}
function renderReadOnlyParams(card) { function renderReadOnlyParams(card) {
const rows = [ const rows = [
{ label: 'Имя', value: card.firstName }, { label: 'Имя', value: card.firstName },
@ -179,14 +276,17 @@ export function render({ navigate, route }) {
const followBtn = body.querySelector('[data-relation-action="follow"]'); const followBtn = body.querySelector('[data-relation-action="follow"]');
const friendBtn = body.querySelector('[data-relation-action="friend"]'); const friendBtn = body.querySelector('[data-relation-action="friend"]');
const contactBtn = body.querySelector('[data-relation-action="contact"]'); const contactBtn = body.querySelector('[data-relation-action="contact"]');
if (!followBtn || !friendBtn || !contactBtn || !currentFlags) return; const opinionBtn = body.querySelector('[data-relation-action="opinion-menu"]');
if (!followBtn || !friendBtn || !contactBtn || !opinionBtn || !currentFlags) return;
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase(); const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
followBtn.textContent = relationButtonLabel('follow', currentFlags);
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
contactBtn.textContent = relationButtonLabel('contact', currentFlags); contactBtn.textContent = relationButtonLabel('contact', currentFlags);
followBtn.disabled = Boolean(isSelf); friendBtn.textContent = relationButtonLabel('friend', currentFlags);
friendBtn.disabled = Boolean(isSelf); followBtn.textContent = relationButtonLabel('follow', currentFlags);
contactBtn.disabled = Boolean(isSelf); contactBtn.disabled = Boolean(isSelf);
friendBtn.disabled = Boolean(isSelf);
followBtn.disabled = Boolean(isSelf);
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить связи' : 'Добавить связь';
opinionBtn.disabled = Boolean(isSelf);
} }
async function refresh() { async function refresh() {
@ -243,6 +343,14 @@ export function render({ navigate, route }) {
return; return;
} }
if (kind === 'opinion-menu') {
openOpinionMenuModal({
flags: currentFlags,
onApply: onOpinionApply,
});
return;
}
const nextEnabled = relationNextState(kind, currentFlags); const nextEnabled = relationNextState(kind, currentFlags);
const confirmed = window.confirm( const confirmed = window.confirm(
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` + `Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
@ -271,13 +379,60 @@ export function render({ navigate, route }) {
} }
} }
async function onOpinionApply({ mode, nextKind, activeKind }) {
if (isBusy || !currentCard || !currentFlags) return;
if (!sessionLogin) {
window.alert('Для изменения связей нужен активный вход.');
return;
}
if (!state.session.storagePwdInMemory) {
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
return;
}
const confirmed = window.confirm(`Изменить мнение о пользователе ${currentCard.login}?`);
if (!confirmed) return;
isBusy = true;
status.className = 'status-line';
status.textContent = 'Сохранение отношения в блокчейн...';
try {
if (activeKind) {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind: activeKind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
if (mode === 'set') {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind: nextKind,
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
}
await refresh();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
isBusy = false;
}
}
body.addEventListener('click', (event) => { body.addEventListener('click', (event) => {
const target = event.target; const target = event.target;
if (!(target instanceof HTMLElement)) return; if (!(target instanceof HTMLElement)) return;
const actionBtn = target.closest('[data-relation-action]'); const actionBtn = target.closest('[data-relation-action]');
const kind = String(actionBtn?.getAttribute('data-relation-action') || ''); const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
if (!kind) return; if (!kind) return;
onRelationAction(kind); void onRelationAction(kind);
}); });
refresh(); refresh();

View File

@ -61,6 +61,9 @@ const CONNECTION_SUBTYPES = Object.freeze({
parent: { on: 50, off: 51 }, parent: { on: 50, off: 51 },
child: { on: 52, off: 53 }, child: { on: 52, off: 53 },
sibling: { on: 54, off: 55 }, sibling: { on: 54, off: 55 },
known_person: { on: 60, off: 61 },
shine_confirmed: { on: 70, off: 71 },
shine_seen: { on: 74, off: 75 },
}); });
function normalizeServerUrl(url) { function normalizeServerUrl(url) {

View File

@ -75,6 +75,12 @@ async function buildRelationsModel(login) {
inChildren: [], inChildren: [],
outSiblings: [], outSiblings: [],
inSiblings: [], inSiblings: [],
outKnownPersons: [],
inKnownPersons: [],
outShineConfirmed: [],
inShineConfirmed: [],
outShineSeen: [],
inShineSeen: [],
}; };
} }
@ -117,6 +123,12 @@ async function buildRelationsModel(login) {
inChildren: readArray(graph, 'inChildren') || [], inChildren: readArray(graph, 'inChildren') || [],
outSiblings: readArray(graph, 'outSiblings') || [], outSiblings: readArray(graph, 'outSiblings') || [],
inSiblings: readArray(graph, 'inSiblings') || [], inSiblings: readArray(graph, 'inSiblings') || [],
outKnownPersons: readArray(graph, 'outKnownPersons') || [],
inKnownPersons: readArray(graph, 'inKnownPersons') || [],
outShineConfirmed: readArray(graph, 'outShineConfirmed') || [],
inShineConfirmed: readArray(graph, 'inShineConfirmed') || [],
outShineSeen: readArray(graph, 'outShineSeen') || [],
inShineSeen: readArray(graph, 'inShineSeen') || [],
}; };
} }
@ -151,6 +163,12 @@ export async function loadCurrentRelations() {
inChildren: [], inChildren: [],
outSiblings: [], outSiblings: [],
inSiblings: [], inSiblings: [],
outKnownPersons: [],
inKnownPersons: [],
outShineConfirmed: [],
inShineConfirmed: [],
outShineSeen: [],
inShineSeen: [],
}; };
} }
return buildRelationsModel(login); return buildRelationsModel(login);
@ -170,6 +188,12 @@ export function relationFlagsForTarget(relations, targetLogin) {
inChild: listContainsLogin(relations?.inChildren, targetLogin), inChild: listContainsLogin(relations?.inChildren, targetLogin),
outSibling: listContainsLogin(relations?.outSiblings, targetLogin), outSibling: listContainsLogin(relations?.outSiblings, targetLogin),
inSibling: listContainsLogin(relations?.inSiblings, targetLogin), inSibling: listContainsLogin(relations?.inSiblings, targetLogin),
outKnownPerson: listContainsLogin(relations?.outKnownPersons, targetLogin),
inKnownPerson: listContainsLogin(relations?.inKnownPersons, targetLogin),
outShineConfirmed: listContainsLogin(relations?.outShineConfirmed, targetLogin),
inShineConfirmed: listContainsLogin(relations?.inShineConfirmed, targetLogin),
outShineSeen: listContainsLogin(relations?.outShineSeen, targetLogin),
inShineSeen: listContainsLogin(relations?.inShineSeen, targetLogin),
}; };
} }

View File

@ -644,6 +644,8 @@
.avatar-image { .avatar-image {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: grid;
place-items: center;
} }
.avatar-image > .avatar-fallback, .avatar-image > .avatar-fallback,
@ -1639,6 +1641,49 @@ textarea.input {
color: transparent; color: transparent;
} }
.user-rel-opinions-wrap {
display: grid;
gap: 8px;
padding: 6px 8px;
border-radius: 10px;
border: 1px dashed rgba(131, 196, 255, 0.45);
background: rgba(9, 18, 31, 0.42);
}
.user-rel-opinions-wrap.is-empty .user-rel-opinions-list {
display: none;
}
.user-rel-opinions-list {
display: grid;
gap: 6px;
}
.user-rel-opinion-item {
color: #d7e6ff;
line-height: 1.35;
font-size: 13px;
}
.user-rel-opinions-hint {
color: rgba(173, 199, 236, 0.9);
font-size: 12px;
}
.user-opinion-modal-btn {
text-align: left;
}
.user-opinion-modal-btn.is-add {
border-color: rgba(97, 170, 255, 0.7);
color: #9fcbff;
}
.user-opinion-modal-btn.is-remove {
border-color: rgba(255, 120, 120, 0.72);
color: #ff9b9b;
}
.tabs { .tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@ -2219,6 +2264,11 @@ textarea.input {
background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89); background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89);
} }
.channel-message-avatar.avatar-image {
display: grid;
place-items: center;
}
.channel-message-author { .channel-message-author {
display: grid; display: grid;
gap: 4px; gap: 4px;

View File

@ -113,6 +113,21 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */ /** Удалить связь "брат/сестра". */
public static final short CONNECTION_UNSIBLING = 55; public static final short CONNECTION_UNSIBLING = 55;
/** Просто знаю этого человека. */
public static final short CONNECTION_KNOWN_PERSON = 60;
/** Не знаю этого человека. */
public static final short CONNECTION_UNKNOWN_PERSON = 61;
/** Точно уверен, что сияющий. */
public static final short CONNECTION_SHINE_CONFIRMED = 70;
/** Не подтверждаю, что сияющий. */
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
/** Мало знаком, но видел сияющим. */
public static final short CONNECTION_SHINE_SEEN = 74;
/** Не отмечаю, что видел сияющим. */
public static final short CONNECTION_SHINE_UNSEEN = 75;
/* ===================== USER_PARAM (msg_type=4) ===================== */ /* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */ /** Параметр профиля key/value (обе строки). */

View File

@ -16,9 +16,13 @@ import java.util.Objects;
* FRIEND=10, UNFRIEND=11 * FRIEND=10, UNFRIEND=11
* CONTACT=20, UNCONTACT=21 * CONTACT=20, UNCONTACT=21
* FOLLOW=30, UNFOLLOW=31 * FOLLOW=30, UNFOLLOW=31
* SPOUSE=40, UNSPOUSE=41
* PARENT=50, UNPARENT=51 * PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53 * CHILD=52, UNCHILD=53
* SIBLING=54, UNSIBLING=55 * SIBLING=54, UNSIBLING=55
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
* SHINE_SEEN=74, SHINE_UNSEEN=75
* *
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ): * bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* [4] lineCode * [4] lineCode
@ -192,7 +196,13 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF) || v == (MsgSubType.CONNECTION_CHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF) || v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF); || v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF)
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
} }
@Override @Override

View File

@ -66,6 +66,15 @@ public final class DatabaseInitializer {
public static final short CONNECTION_SIBLING = 54; public static final short CONNECTION_SIBLING = 54;
public static final short CONNECTION_UNSIBLING = 55; public static final short CONNECTION_UNSIBLING = 55;
public static final short CONNECTION_KNOWN_PERSON = 60;
public static final short CONNECTION_UNKNOWN_PERSON = 61;
public static final short CONNECTION_SHINE_CONFIRMED = 70;
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
public static final short CONNECTION_SHINE_SEEN = 74;
public static final short CONNECTION_SHINE_UNSEEN = 75;
public static void createNewDB(String[] args) { public static void createNewDB(String[] args) {
AppConfig config = AppConfig.getInstance(); AppConfig config = AppConfig.getInstance();
String dbPath = config.getParam("db.path"); String dbPath = config.getParam("db.path");

View File

@ -198,6 +198,9 @@ public final class DatabaseTriggersInstaller {
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT; int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD; int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING; int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING;
int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON;
int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED;
int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN;
int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND; int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT; int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
@ -206,13 +209,16 @@ public final class DatabaseTriggersInstaller {
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT; int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD; int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING; int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING;
int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON;
int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED;
int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN;
st.executeUpdate(""" st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
AFTER INSERT ON blocks AFTER INSERT ON blocks
WHEN NEW.msg_type = 3 WHEN NEW.msg_type = 3
BEGIN BEGIN
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING: -- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*:
-- 1) если записи РЅРµС вЂ СЃРѕР·РґР°СРј -- 1) если записи РЅРµС вЂ СЃРѕР·РґР°СРј
INSERT OR IGNORE INTO connections_state ( INSERT OR IGNORE INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
@ -233,7 +239,7 @@ public final class DatabaseTriggersInstaller {
NEW.to_bch_name, NEW.to_bch_name,
NEW.to_block_number, NEW.to_block_number,
NEW.to_block_hash NEW.to_block_hash
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d) WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
AND COALESCE( AND COALESCE(
NEW.to_login, NEW.to_login,
CASE CASE
@ -264,7 +270,7 @@ public final class DatabaseTriggersInstaller {
ELSE NULL ELSE NULL
END END
) )
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d) AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
AND COALESCE( AND COALESCE(
NEW.to_login, NEW.to_login,
CASE CASE
@ -277,7 +283,7 @@ public final class DatabaseTriggersInstaller {
) IS NOT NULL ) IS NOT NULL
AND NEW.to_bch_name IS NOT NULL; AND NEW.to_bch_name IS NOT NULL;
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING: -- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*:
-- удаляем СЃРѕРѕСРІРµССЃСРІСѓСЋСее "позитивное" СЃРѕСЃСРѕСЏРЅРёРµ -- удаляем СЃРѕРѕСРІРµССЃСРІСѓСЋСее "позитивное" СЃРѕСЃСРѕСЏРЅРёРµ
DELETE FROM connections_state DELETE FROM connections_state
WHERE login = NEW.login WHERE login = NEW.login
@ -299,6 +305,9 @@ public final class DatabaseTriggersInstaller {
WHEN %d THEN %d WHEN %d THEN %d
WHEN %d THEN %d WHEN %d THEN %d
WHEN %d THEN %d WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
ELSE rel_type ELSE rel_type
END END
AND COALESCE( AND COALESCE(
@ -311,11 +320,11 @@ public final class DatabaseTriggersInstaller {
ELSE NULL ELSE NULL
END END
) IS NOT NULL ) IS NOT NULL
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d); AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d);
END; END;
""".formatted( """.formatted(
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
UNFRIEND, FRIEND, UNFRIEND, FRIEND,
UNCONTACT, CONTACT, UNCONTACT, CONTACT,
@ -324,8 +333,11 @@ public final class DatabaseTriggersInstaller {
UNPARENT, PARENT, UNPARENT, PARENT,
UNCHILD, CHILD, UNCHILD, CHILD,
UNSIBLING, SIBLING, UNSIBLING, SIBLING,
UNKNOWN, KNOWN,
SHINE_UNCONF, SHINE_CONF,
SHINE_UNSEEN, SHINE_SEEN,
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN
)); ));
} }

View File

@ -40,8 +40,10 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== CONNECTION (msg_type=3) ===================== */
/** /**
* Совпадает с ConnectionBody: * Совпадает с ConnectionBody:
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54 * SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55 * KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
*/ */
/** Добавить в близкие друзья (close friend). */ /** Добавить в близкие друзья (close friend). */
@ -92,6 +94,24 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */ /** Удалить связь "брат/сестра". */
public static final short CONNECTION_UNSIBLING = 55; public static final short CONNECTION_UNSIBLING = 55;
/** Просто знаю этого человека. */
public static final short CONNECTION_KNOWN_PERSON = 60;
/** Не знаю этого человека. */
public static final short CONNECTION_UNKNOWN_PERSON = 61;
/** Точно уверен, что сияющий. */
public static final short CONNECTION_SHINE_CONFIRMED = 70;
/** Не подтверждаю, что сияющий. */
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
/** Мало знаком, но видел сияющим. */
public static final short CONNECTION_SHINE_SEEN = 74;
/** Не отмечаю, что видел сияющим. */
public static final short CONNECTION_SHINE_UNSEEN = 75;
/* ===================== USER_PARAM (msg_type=4) ===================== */ /* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */ /** Параметр профиля key/value (обе строки). */

View File

@ -55,11 +55,18 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD); List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING); List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING); List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
List<String> outKnownPersons = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
List<String> inKnownPersons = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
List<String> outShineConfirmed = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
List<String> inShineConfirmed = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
List<String> outShineSeen = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
List<String> inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
LinkedHashSet<String> allLogins = new LinkedHashSet<>(); LinkedHashSet<String> allLogins = new LinkedHashSet<>();
allLogins.add(canonicalLogin); allLogins.add(canonicalLogin);
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows, addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings); outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings,
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins); Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses); List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
@ -86,6 +93,12 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
resp.setInChildren(inChildren); resp.setInChildren(inChildren);
resp.setOutSiblings(outSiblings); resp.setOutSiblings(outSiblings);
resp.setInSiblings(inSiblings); resp.setInSiblings(inSiblings);
resp.setOutKnownPersons(outKnownPersons);
resp.setInKnownPersons(inKnownPersons);
resp.setOutShineConfirmed(outShineConfirmed);
resp.setInShineConfirmed(inShineConfirmed);
resp.setOutShineSeen(outShineSeen);
resp.setInShineSeen(inShineSeen);
resp.setParents(toRelativeItems(parentLogins, metaByLogin)); resp.setParents(toRelativeItems(parentLogins, metaByLogin));
resp.setChildren(toRelativeItems(childLogins, metaByLogin)); resp.setChildren(toRelativeItems(childLogins, metaByLogin));
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin)); resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));

View File

@ -21,6 +21,12 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private List<String> inChildren = new ArrayList<>(); private List<String> inChildren = new ArrayList<>();
private List<String> outSiblings = new ArrayList<>(); private List<String> outSiblings = new ArrayList<>();
private List<String> inSiblings = new ArrayList<>(); private List<String> inSiblings = new ArrayList<>();
private List<String> outKnownPersons = new ArrayList<>();
private List<String> inKnownPersons = new ArrayList<>();
private List<String> outShineConfirmed = new ArrayList<>();
private List<String> inShineConfirmed = new ArrayList<>();
private List<String> outShineSeen = new ArrayList<>();
private List<String> inShineSeen = new ArrayList<>();
private List<RelativeItem> parents = new ArrayList<>(); private List<RelativeItem> parents = new ArrayList<>();
private List<RelativeItem> children = new ArrayList<>(); private List<RelativeItem> children = new ArrayList<>();
private List<RelativeItem> siblings = new ArrayList<>(); private List<RelativeItem> siblings = new ArrayList<>();
@ -102,6 +108,18 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public void setOutSiblings(List<String> outSiblings) { this.outSiblings = outSiblings; } public void setOutSiblings(List<String> outSiblings) { this.outSiblings = outSiblings; }
public List<String> getInSiblings() { return inSiblings; } public List<String> getInSiblings() { return inSiblings; }
public void setInSiblings(List<String> inSiblings) { this.inSiblings = inSiblings; } public void setInSiblings(List<String> inSiblings) { this.inSiblings = inSiblings; }
public List<String> getOutKnownPersons() { return outKnownPersons; }
public void setOutKnownPersons(List<String> outKnownPersons) { this.outKnownPersons = outKnownPersons; }
public List<String> getInKnownPersons() { return inKnownPersons; }
public void setInKnownPersons(List<String> inKnownPersons) { this.inKnownPersons = inKnownPersons; }
public List<String> getOutShineConfirmed() { return outShineConfirmed; }
public void setOutShineConfirmed(List<String> outShineConfirmed) { this.outShineConfirmed = outShineConfirmed; }
public List<String> getInShineConfirmed() { return inShineConfirmed; }
public void setInShineConfirmed(List<String> inShineConfirmed) { this.inShineConfirmed = inShineConfirmed; }
public List<String> getOutShineSeen() { return outShineSeen; }
public void setOutShineSeen(List<String> outShineSeen) { this.outShineSeen = outShineSeen; }
public List<String> getInShineSeen() { return inShineSeen; }
public void setInShineSeen(List<String> inShineSeen) { this.inShineSeen = inShineSeen; }
public List<RelativeItem> getParents() { return parents; } public List<RelativeItem> getParents() { return parents; }
public void setParents(List<RelativeItem> parents) { this.parents = parents; } public void setParents(List<RelativeItem> parents) { this.parents = parents; }
public List<RelativeItem> getChildren() { return children; } public List<RelativeItem> getChildren() { return children; }