feat(presence): ajouter un onglet de gestion du statut de présence dans les paramètres utilisateur

watcha-develop
dlamarcheteamnet 6 days ago
parent 08035c4e45
commit 5be2596c0b
  1. 1
      res/css/_components.pcss
  2. 29
      res/css/structures/_UserMenu.pcss
  3. 6
      res/css/views/dialogs/_UserSettingsDialog.pcss
  4. 50
      res/css/views/settings/tabs/user/_watcha_PresenceUserSettingsTab.pcss
  5. 33
      src/Presence.ts
  6. 44
      src/components/structures/UserMenu.tsx
  7. 15
      src/components/views/dialogs/UserSettingsDialog.tsx
  8. 1
      src/components/views/dialogs/UserTab.ts
  9. 81
      src/components/views/settings/tabs/user/watcha_PresenceUserSettingsTab.tsx
  10. 1
      src/i18n/strings/en_EN.json
  11. 1
      src/i18n/strings/fr.json

@ -404,4 +404,5 @@
@import "./views/settings/tabs/room/_watcha_CalendarSettingsTab.pcss";
@import "./views/settings/tabs/room/_watcha_DocumentsSettingsTab.pcss";
@import "./views/settings/tabs/user/_watcha_SSOProfile.pcss";
@import "./views/settings/tabs/user/_watcha_PresenceUserSettingsTab.pcss";
// +watcha

@ -228,35 +228,6 @@ limitations under the License.
.mx_UserMenu_iconJitsi::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
// Sélecteur de statut : pastilles de couleur (disponible / occupé / absent / automatique)
.mx_UserMenu_iconStatusAvailable::before,
.mx_UserMenu_iconStatusBusy::before,
.mx_UserMenu_iconStatusAway::before,
.mx_UserMenu_iconStatusAuto::before {
mask: none;
-webkit-mask: none;
width: 10px;
height: 10px;
margin: 3px;
border-radius: 50%;
}
.mx_UserMenu_iconStatusAvailable::before {
background-color: #2dbd59; // vert : disponible
}
.mx_UserMenu_iconStatusBusy::before {
background-color: #ff5b55; // rouge : occupé
}
.mx_UserMenu_iconStatusAway::before {
background-color: #fdbd64; // orange : absent
}
.mx_UserMenu_iconStatusAuto::before {
background-color: $secondary-content; // gris : détection automatique
}
// +watcha
}

@ -72,3 +72,9 @@ limitations under the License.
.mx_UserSettingsDialog_mjolnirIcon::before {
mask-image: url("$(res)/img/element-icons/room/composer/emoji.svg");
}
/* watcha+ : onglet « Statut » (présence) */
.mx_UserSettingsDialog_watcha_presenceIcon::before {
mask-image: url("$(res)/img/element-icons/room/members.svg");
}
/* +watcha */

@ -0,0 +1,50 @@
/*
Copyright 2026 Watcha
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.watcha_PresenceUserSettingsTab_option {
margin-bottom: var(--cpd-space-3x);
.mx_StyledRadioButton_content {
display: flex;
align-items: center;
}
}
/* Pastille de couleur correspondant au statut */
.watcha_PresenceUserSettingsTab_dot {
display: inline-block;
width: 10px;
height: 10px;
margin-right: var(--cpd-space-2x);
border-radius: 50%;
flex-shrink: 0;
}
.watcha_PresenceUserSettingsTab_dot_available {
background-color: #2dbd59; /* vert : disponible */
}
.watcha_PresenceUserSettingsTab_dot_busy {
background-color: #ff5b55; /* rouge : occupé */
}
.watcha_PresenceUserSettingsTab_dot_away {
background-color: #fdbd64; /* orange : absent */
}
.watcha_PresenceUserSettingsTab_dot_auto {
background-color: $secondary-content; /* gris : détection automatique */
}

@ -131,33 +131,36 @@ class Presence {
await SettingsStore.setValue(MANUAL_PRESENCE_SETTING, null, SettingLevel.DEVICE, presence);
}
if (presence === null) {
// Reprise du mode automatique : on repasse en ligne et on réarme le minuteur d'inactivité.
this.state = null;
await this.setState(SetPresence.Online);
this.unavailableTimer?.restart();
return;
}
if (MatrixClientPeg.safeGet().isGuest()) {
return; // don't try to set presence when a guest; it won't work.
}
// Le paramètre set_presence des appels /sync n'accepte que online/unavailable/offline.
// L'état "busy" (MSC3026) est conservé côté serveur et ne doit donc être poussé que via
// l'API /presence/{userId}/status, sans modifier la présence de synchronisation.
if (presence === ManualPresence.Away) {
// État effectif à pousser au serveur ; en mode automatique (null) on repart de "online".
const target: ManualPresence = presence ?? ManualPresence.Available;
// Aligne la présence de synchronisation (paramètre set_presence des /sync), qui n'accepte
// que online/unavailable/offline. Pour "busy" on laisse la synchro sur "online".
if (target === ManualPresence.Away) {
await this.setState(SetPresence.Unavailable);
} else if (presence === ManualPresence.Available) {
} else {
this.state = null; // force le ré-envoi même si déjà "online" (cas sortie de "busy")
await this.setState(SetPresence.Online);
}
// Un setPresence direct (PUT /presence/{userId}/status) est INDISPENSABLE pour sortir de
// l'état "busy" : Synapse protège "busy" contre l'écrasement par le set_presence des /sync
// (MSC3026). Sans cet appel, le statut "occupé" resterait collé côté serveur.
try {
await MatrixClientPeg.safeGet().setPresence({ presence });
logger.debug("Manual presence:", presence);
await MatrixClientPeg.safeGet().setPresence({ presence: target });
logger.debug("Manual presence:", presence ?? "automatic");
} catch (err) {
logger.error("Failed to set manual presence:", err);
}
// Reprise de la détection automatique d'inactivité.
if (presence === null) {
this.unavailableTimer?.restart();
}
}
// +watcha

@ -41,7 +41,6 @@ import { SettingLevel } from "../../settings/SettingLevel";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
IconizedContextMenuRadio, // watcha+
} from "../views/context_menus/IconizedContextMenu";
import { UIFeature } from "../../settings/UIFeature";
import SpaceStore from "../../stores/spaces/SpaceStore";
@ -51,7 +50,6 @@ import UserIdentifierCustomisations from "../../customisations/UserIdentifier";
import PosthogTrackers from "../../PosthogTrackers";
import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
import { getNextcloudBaseUrl } from "../../utils/watcha_nextcloudUtils"; // watcha+
import Presence, { ManualPresence } from "../../Presence"; // watcha+
import { Jitsi } from "../../widgets/Jitsi"; // watcha+
import { Icon as LiveIcon } from "../../../res/img/compound/live-8px.svg";
import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStoreEvent } from "../../voice-broadcast";
@ -379,15 +377,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
window.open(emailBaseUrl);
this.setState({ contextMenuPosition: null }); // also close the menu
};
// Statut de présence choisi manuellement (disponible / absent / occupé) ou retour au mode automatique.
private onPresenceClick = (ev: ButtonEvent, presence: ManualPresence | null): void => {
ev.preventDefault();
ev.stopPropagation();
Presence.setManualPresence(presence);
this.setState({ contextMenuPosition: null }); // also close the menu
};
// +watcha
private renderContextMenu = (): React.ReactNode => {
@ -470,41 +459,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
);
}
// watcha+ : sélecteur de statut de présence (disponible / absent / occupé)
const currentManualPresence = Presence.getManualPresence();
const statusOptionList = (
<IconizedContextMenuOptionList label={_t("watcha|status_title")}>
<IconizedContextMenuRadio
iconClassName="mx_UserMenu_iconStatusAvailable"
label={_t("presence|online")}
active={currentManualPresence === ManualPresence.Available}
onClick={(e) => this.onPresenceClick(e, ManualPresence.Available)}
/>
<IconizedContextMenuRadio
iconClassName="mx_UserMenu_iconStatusBusy"
label={_t("presence|busy")}
active={currentManualPresence === ManualPresence.Busy}
onClick={(e) => this.onPresenceClick(e, ManualPresence.Busy)}
/>
<IconizedContextMenuRadio
iconClassName="mx_UserMenu_iconStatusAway"
label={_t("presence|away")}
active={currentManualPresence === ManualPresence.Away}
onClick={(e) => this.onPresenceClick(e, ManualPresence.Away)}
/>
<IconizedContextMenuRadio
iconClassName="mx_UserMenu_iconStatusAuto"
label={_t("watcha|status_automatic")}
active={currentManualPresence === null}
onClick={(e) => this.onPresenceClick(e, null)}
/>
</IconizedContextMenuOptionList>
);
// +watcha
let primaryOptionList = (
<> { /* eslint-disable indent *//* watcha+ */ }
{statusOptionList}
<IconizedContextMenuOptionList>
{homeButton}
{linkNewDeviceButton}

@ -38,6 +38,7 @@ import KeyboardUserSettingsTab from "../settings/tabs/user/KeyboardUserSettingsT
import { UserTab } from "./UserTab";
import { MatrixClientPeg } from '../../../MatrixClientPeg'; // watcha+
import SSOProfileTab from "../settings/tabs/user/watcha_SSOProfileTab"; // watcha+
import PresenceUserSettingsTab from "../settings/tabs/user/watcha_PresenceUserSettingsTab"; // watcha+
import SdkConfig from "../../../SdkConfig";// watcha+
import { NonEmptyArray } from "../../../@types/common";
import { SDKContext, SdkContextClass } from "../../../contexts/SDKContext";
@ -80,6 +81,10 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
return _t("settings|labs_mjolnir|dialog_title", undefined, subs);
case UserTab.Help:
return _t("setting|help_about|dialog_title", undefined, subs);
// watcha+
case UserTab.Presence:
return _t("watcha|status_title");
// +watcha
}
}
@ -101,6 +106,16 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
"UserSettingsGeneral",
),
);
// watcha+ : onglet de gestion du statut de présence (disponible / occupé / absent)
tabs.push(
new Tab(
UserTab.Presence,
_td("watcha|status_title"),
"mx_UserSettingsDialog_watcha_presenceIcon",
<PresenceUserSettingsTab />,
),
);
// +watcha
/* watcha!
tabs.push(
new Tab(

@ -27,5 +27,6 @@ export enum UserTab {
Mjolnir = "USER_MJOLNIR_TAB",
Help = "USER_HELP_TAB",
SSOProfile = "USER_SSO_PROFILE_TAB", // watcha+
Presence = "USER_PRESENCE_TAB", // watcha+
SessionManager = "USER_SESSION_MANAGER_TAB",
}

@ -0,0 +1,81 @@
/*
Copyright 2026 Watcha
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState } from "react";
import classNames from "classnames";
import { _t } from "../../../../../languageHandler";
import Presence, { ManualPresence } from "../../../../../Presence";
import StyledRadioButton from "../../../elements/StyledRadioButton";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection from "../../shared/SettingsSubsection";
// Valeur de remplacement pour le mode automatique (un <input radio> ne peut pas porter `null`).
const AUTOMATIC = "automatic";
interface PresenceOption {
// valeur du radio ; null = mode automatique
value: ManualPresence | null;
label: string;
// suffixe de la classe CSS de la pastille de couleur
dot: "available" | "busy" | "away" | "auto";
}
const PresenceUserSettingsTab: React.FC = () => {
const [current, setCurrent] = useState<ManualPresence | null>(Presence.getManualPresence());
const options: PresenceOption[] = [
{ value: ManualPresence.Available, label: _t("presence|online"), dot: "available" },
{ value: ManualPresence.Busy, label: _t("presence|busy"), dot: "busy" },
{ value: ManualPresence.Away, label: _t("presence|away"), dot: "away" },
{ value: null, label: _t("watcha|status_automatic"), dot: "auto" },
];
const onChange = useCallback((presence: ManualPresence | null): void => {
setCurrent(presence);
Presence.setManualPresence(presence);
}, []);
return (
<SettingsTab>
<SettingsSection heading={_t("watcha|status_title")}>
<SettingsSubsection description={_t("watcha|status_description")}>
{options.map((option) => (
<StyledRadioButton
key={option.value ?? AUTOMATIC}
className="watcha_PresenceUserSettingsTab_option"
name="watcha_manualPresence"
value={option.value ?? AUTOMATIC}
checked={current === option.value}
onChange={() => onChange(option.value)}
>
<span
className={classNames(
"watcha_PresenceUserSettingsTab_dot",
`watcha_PresenceUserSettingsTab_dot_${option.dot}`,
)}
/>
{option.label}
</StyledRadioButton>
))}
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
};
export default PresenceUserSettingsTab;

@ -4032,6 +4032,7 @@
"show_task": "Show the to-do list shared with the room",
"sso_profile": "SSO Profile",
"status_automatic": "Automatic",
"status_description": "Choose the status shown to other users. In automatic mode, your status follows your activity (online, then idle).",
"status_title": "Status",
"stop_sharing": "Stop sharing",
"stop_sharing_description": "Please note that only the owner of this resource will be able to share it again.",

@ -4034,6 +4034,7 @@
"show_task": "Afficher la liste de tâches partagée avec le salon",
"sso_profile": "Profil SSO",
"status_automatic": "Automatique",
"status_description": "Choisissez le statut affiché aux autres utilisateurs. En mode automatique, votre statut suit votre activité (en ligne, puis inactif).",
"status_title": "Statut",
"stop_sharing": "Arrêter de partager",
"stop_sharing_description": "Veuillez noter que seul le propriétaire de cette ressource pourra la partager à nouveau.",

Loading…
Cancel
Save