feat(profile): 'Active now' / 'Last active …' line from presence (#150)
One line under the handle in the user profile popover, from the presence the
SDK already receives (currently_active + last_active_ago): 'Active now',
'Active just now', 'Last active 12 min ago / 3 hours ago / yesterday / 5 days
ago / over a month ago'. Nothing is shown without presence data or for an
offline user with no timestamp (Hide Online Status users), so nothing new is
exposed; re-renders on the existing presence events, no polling. Wording
unit-tested; verified headless ('Active now' for an online member).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -19,6 +19,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
|
|||||||
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||||
import { ModalMobileFull } from '../../styles/Modal.css';
|
import { ModalMobileFull } from '../../styles/Modal.css';
|
||||||
import { UserPresence } from '../../hooks/useUserPresence';
|
import { UserPresence } from '../../hooks/useUserPresence';
|
||||||
|
import { describeLastActive } from '../../utils/lastActive';
|
||||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||||
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||||
import { ImageViewer } from '../image-viewer';
|
import { ImageViewer } from '../image-viewer';
|
||||||
@@ -110,6 +111,8 @@ type UserHeroNameProps = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
pronouns?: string;
|
pronouns?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
|
/** [Gitea #150] Presence for the "Active now / Last active …" line. */
|
||||||
|
presence?: UserPresence;
|
||||||
};
|
};
|
||||||
export function UserHeroName({
|
export function UserHeroName({
|
||||||
displayName,
|
displayName,
|
||||||
@@ -117,7 +120,9 @@ export function UserHeroName({
|
|||||||
status,
|
status,
|
||||||
pronouns,
|
pronouns,
|
||||||
timezone,
|
timezone,
|
||||||
|
presence,
|
||||||
}: UserHeroNameProps) {
|
}: UserHeroNameProps) {
|
||||||
|
const lastActive = describeLastActive(presence);
|
||||||
const username = getMxIdLocalPart(userId);
|
const username = getMxIdLocalPart(userId);
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const localTimeInfo = useLocalTime(timezone, !hour24Clock);
|
const localTimeInfo = useLocalTime(timezone, !hour24Clock);
|
||||||
@@ -158,6 +163,13 @@ export function UserHeroName({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{lastActive && (
|
||||||
|
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
||||||
|
<Text size="T200" style={{ opacity: 0.6 }}>
|
||||||
|
{lastActive}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{status && (
|
{status && (
|
||||||
<Box alignItems="Center" gap="100" style={{ marginTop: '2px', overflow: 'hidden' }}>
|
<Box alignItems="Center" gap="100" style={{ marginTop: '2px', overflow: 'hidden' }}>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
status={presence?.status}
|
status={presence?.status}
|
||||||
|
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
||||||
pronouns={extProfile.pronouns}
|
pronouns={extProfile.pronouns}
|
||||||
timezone={extProfile.timezone}
|
timezone={extProfile.timezone}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describeLastActive } from './lastActive';
|
||||||
|
|
||||||
|
const now = 1_800_000_000_000;
|
||||||
|
test('wording buckets', () => {
|
||||||
|
assert.equal(describeLastActive(undefined), null);
|
||||||
|
assert.equal(describeLastActive({ presence: 'offline', active: false }), null);
|
||||||
|
assert.equal(describeLastActive({ presence: 'offline', active: false, lastActiveTs: 0 }), null);
|
||||||
|
assert.equal(
|
||||||
|
describeLastActive({ presence: 'online', active: true, lastActiveTs: now }, now),
|
||||||
|
'Active now',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
describeLastActive({ presence: 'online', active: false, lastActiveTs: now - 20_000 }, now),
|
||||||
|
'Active just now',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
describeLastActive(
|
||||||
|
{ presence: 'unavailable', active: false, lastActiveTs: now - 12 * 60_000 },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
'Last active 12 min ago',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
describeLastActive(
|
||||||
|
{ presence: 'offline', active: false, lastActiveTs: now - 3 * 3_600_000 },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
'Last active 3 hours ago',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
describeLastActive(
|
||||||
|
{ presence: 'offline', active: false, lastActiveTs: now - 26 * 3_600_000 },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
'Last active yesterday',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
describeLastActive(
|
||||||
|
{ presence: 'offline', active: false, lastActiveTs: now - 5 * 86_400_000 },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
'Last active 5 days ago',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
describeLastActive(
|
||||||
|
{ presence: 'offline', active: false, lastActiveTs: now - 40 * 86_400_000 },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
'Last active over a month ago',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #150] "Active now" / "Last active 12 min ago" from presence.
|
||||||
|
* Returns null when there is nothing honest to say: no presence data, an
|
||||||
|
* offline user with no timestamp (e.g. someone hiding their online status).
|
||||||
|
*/
|
||||||
|
export const describeLastActive = (
|
||||||
|
presence: { presence: string; active: boolean; lastActiveTs?: number } | undefined,
|
||||||
|
now = Date.now(),
|
||||||
|
): string | null => {
|
||||||
|
if (!presence) return null;
|
||||||
|
if (presence.presence === 'online' && presence.active) return 'Active now';
|
||||||
|
const ts = presence.lastActiveTs;
|
||||||
|
if (!ts || ts <= 0) return null;
|
||||||
|
const ago = Math.max(0, now - ts);
|
||||||
|
const min = Math.round(ago / 60_000);
|
||||||
|
if (min < 1) return 'Active just now';
|
||||||
|
if (min < 60) return `Last active ${min} min ago`;
|
||||||
|
const hours = Math.round(min / 60);
|
||||||
|
if (hours < 24) return `Last active ${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
if (days === 1) return 'Last active yesterday';
|
||||||
|
if (days < 30) return `Last active ${days} days ago`;
|
||||||
|
return 'Last active over a month ago';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user