Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions lib/api/model/initial_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ class InitialSnapshot {

final int maxFileUploadSizeMib;

@JsonKey(defaultValue: []) // TODO(server-9) remove default value
final List<ThumbnailFormat> serverThumbnailFormats;

final Uri serverEmojiDataUrl;

final String? realmEmptyTopicDisplayName; // TODO(server-10)
Expand Down Expand Up @@ -194,6 +197,7 @@ class InitialSnapshot {
required this.realmPresenceDisabled,
required this.realmDefaultExternalAccounts,
required this.maxFileUploadSizeMib,
required this.serverThumbnailFormats,
required this.serverEmojiDataUrl,
required this.realmEmptyTopicDisplayName,
required this.realmUsers,
Expand Down Expand Up @@ -262,6 +266,32 @@ class RealmDefaultExternalAccount {
Map<String, dynamic> toJson() => _$RealmDefaultExternalAccountToJson(this);
}

/// An item in `server_thumbnail_formats`.
///
/// For docs, search for "server_thumbnail_formats:"
/// in <https://zulip.com/api/register-queue>.
@JsonSerializable(fieldRename: FieldRename.snake)
class ThumbnailFormat {
ThumbnailFormat({
required this.name,
required this.maxWidth,
required this.maxHeight,
required this.animated,
required this.format,
});

final String name;
final int maxWidth;
final int maxHeight;
final bool animated;
final String format;

factory ThumbnailFormat.fromJson(Map<String, dynamic> json) =>
_$ThumbnailFormatFromJson(json);

Map<String, dynamic> toJson() => _$ThumbnailFormatToJson(this);
}

/// An item in `recent_private_conversations`.
///
/// For docs, search for "recent_private_conversations:"
Expand Down
24 changes: 24 additions & 0 deletions lib/api/model/initial_snapshot.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 55 additions & 14 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:html/parser.dart';

import '../api/model/model.dart';
import '../api/model/submessage.dart';
import '../widgets/image.dart';
import 'code_block.dart';
import 'katex.dart';

Expand Down Expand Up @@ -539,7 +540,7 @@ class ImagePreviewNode extends BlockContentNode {
const ImagePreviewNode({
super.debugHtmlNode,
required this.srcUrl,
required this.thumbnailUrl,
required this.thumbnail,
required this.loading,
required this.originalWidth,
required this.originalHeight,
Expand All @@ -551,15 +552,16 @@ class ImagePreviewNode extends BlockContentNode {
/// authentication credentials to the request.
final String srcUrl;

/// The thumbnail URL of the image.
/// The thumbnail URL of the image and whether it has an animated version.
///
/// This may be a relative URL string. It also may not work without adding
/// authentication credentials to the request.
/// Use [ImageThumbnailLocatorExtension.resolve] to obtain a suitable URL
/// for the current UI need.
/// It may not work without adding authentication credentials to the request.
///
/// This will be null if the server hasn't yet generated a thumbnail,
/// or is a version that doesn't offer thumbnails.
/// It will also be null when [loading] is true.
final String? thumbnailUrl;
final ImageThumbnailLocator? thumbnail;

/// A flag to indicate whether to show the placeholder.
///
Expand All @@ -576,27 +578,64 @@ class ImagePreviewNode extends BlockContentNode {
bool operator ==(Object other) {
return other is ImagePreviewNode
&& other.srcUrl == srcUrl
&& other.thumbnailUrl == thumbnailUrl
&& other.thumbnail == thumbnail
&& other.loading == loading
&& other.originalWidth == originalWidth
&& other.originalHeight == originalHeight;
}

@override
int get hashCode => Object.hash('ImagePreviewNode',
srcUrl, thumbnailUrl, loading, originalWidth, originalHeight);
srcUrl, thumbnail, loading, originalWidth, originalHeight);

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('srcUrl', srcUrl));
properties.add(StringProperty('thumbnailUrl', thumbnailUrl));
properties.add(DiagnosticsProperty<ImageThumbnailLocator>('thumbnail', thumbnail));
properties.add(FlagProperty('loading', value: loading, ifTrue: "is loading"));
properties.add(DoubleProperty('originalWidth', originalWidth));
properties.add(DoubleProperty('originalHeight', originalHeight));
}
}

/// Data to locate an image thumbnail,
/// and whether the image has an animated version.
///
/// Use [ImageThumbnailLocatorExtension.resolve] to obtain a suitable URL
/// for the current UI need.
@immutable
class ImageThumbnailLocator extends DiagnosticableTree {
ImageThumbnailLocator({
required this.urlPath,
required this.hasAnimatedVersion,
}) : assert(urlPath.startsWith(urlPathPrefix));

final String urlPath;
final bool hasAnimatedVersion;

static const urlPathPrefix = '/user_uploads/thumbnail/';

@override
bool operator ==(Object other) {
if (other is! ImageThumbnailLocator) return false;
return urlPath == other.urlPath
&& hasAnimatedVersion == other.hasAnimatedVersion;
}

@override
int get hashCode => Object.hash(urlPath, hasAnimatedVersion);

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('urlPath', urlPath));
properties.add(FlagProperty('hasAnimatedVersion', value: hasAnimatedVersion,
ifTrue: 'animatable',
ifFalse: 'not animatable'));
}
}

class InlineVideoNode extends BlockContentNode {
const InlineVideoNode({
super.debugHtmlNode,
Expand Down Expand Up @@ -1399,7 +1438,7 @@ class _ZulipContentParser {
if (imgElement.className == 'image-loading-placeholder') {
return ImagePreviewNode(
srcUrl: href,
thumbnailUrl: null,
thumbnail: null,
loading: true,
originalWidth: null,
originalHeight: null,
Expand All @@ -1411,19 +1450,21 @@ class _ZulipContentParser {
}

final String srcUrl;
final String? thumbnailUrl;
if (src.startsWith('/user_uploads/thumbnail/')) {
final ImageThumbnailLocator? thumbnail;
if (src.startsWith(ImageThumbnailLocator.urlPathPrefix)) {
// For why we recognize this as the thumbnail form, see discussion:
// https://chat.zulip.org/#narrow/channel/412-api-documentation/topic/documenting.20inline.20images/near/2279872
srcUrl = href;
thumbnailUrl = src;
thumbnail = ImageThumbnailLocator(
urlPath: src,
hasAnimatedVersion: imgElement.attributes['data-animated'] == 'true');
} else {
// Known cases this handles:
// - `src` starts with CAMO_URI, a server variable (e.g. on Zulip Cloud
// it's "https://uploads.zulipusercontent.net/" in 2025-10).
// - `src` matches `href`, e.g. from pre-thumbnailing servers.
srcUrl = src;
thumbnailUrl = null;
thumbnail = null;
}

double? originalWidth, originalHeight;
Expand All @@ -1447,7 +1488,7 @@ class _ZulipContentParser {

return ImagePreviewNode(
srcUrl: srcUrl,
thumbnailUrl: thumbnailUrl,
thumbnail: thumbnail,
loading: false,
originalWidth: originalWidth,
originalHeight: originalHeight,
Expand Down
48 changes: 48 additions & 0 deletions lib/model/realm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ mixin RealmStore on PerAccountStoreBase, UserGroupStore {
Duration get serverTypingStartedWaitPeriod => Duration(milliseconds: serverTypingStartedWaitPeriodMilliseconds);
int get serverTypingStartedWaitPeriodMilliseconds;

List<ThumbnailFormat> get serverThumbnailFormats;
/// A digest of [serverThumbnailFormats]: sorted by max resolution, ascending,
/// and filtered to those with `animated: true`.
List<ThumbnailFormat> get sortedAnimatedThumbnailFormats;
/// A digest of [serverThumbnailFormats]: sorted by max resolution, ascending,
/// and filtered to those with `animated: false`.
List<ThumbnailFormat> get sortedStillThumbnailFormats;

//|//////////////////////////////////////////////////////////////
// Realm settings.

Expand Down Expand Up @@ -166,6 +174,12 @@ mixin ProxyRealmStore on RealmStore {
@override
int get serverTypingStartedWaitPeriodMilliseconds => realmStore.serverTypingStartedWaitPeriodMilliseconds;
@override
List<ThumbnailFormat> get serverThumbnailFormats => realmStore.serverThumbnailFormats;
@override
List<ThumbnailFormat> get sortedAnimatedThumbnailFormats => realmStore.sortedAnimatedThumbnailFormats;
@override
List<ThumbnailFormat> get sortedStillThumbnailFormats => realmStore.sortedStillThumbnailFormats;
@override
bool get realmAllowMessageEditing => realmStore.realmAllowMessageEditing;
@override
GroupSettingValue? get realmCanDeleteAnyMessageGroup => realmStore.realmCanDeleteAnyMessageGroup;
Expand Down Expand Up @@ -230,6 +244,11 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
serverTypingStartedExpiryPeriodMilliseconds = initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds,
serverTypingStoppedWaitPeriodMilliseconds = initialSnapshot.serverTypingStoppedWaitPeriodMilliseconds,
serverTypingStartedWaitPeriodMilliseconds = initialSnapshot.serverTypingStartedWaitPeriodMilliseconds,
serverThumbnailFormats = initialSnapshot.serverThumbnailFormats,
_sortedAnimatedThumbnailFormats = _filterAndSortThumbnailFormats(
initialSnapshot.serverThumbnailFormats, animated: true),
_sortedStillThumbnailFormats = _filterAndSortThumbnailFormats(
initialSnapshot.serverThumbnailFormats, animated: false),
realmAllowMessageEditing = initialSnapshot.realmAllowMessageEditing,
realmCanDeleteAnyMessageGroup = initialSnapshot.realmCanDeleteAnyMessageGroup,
realmCanDeleteOwnMessageGroup = initialSnapshot.realmCanDeleteOwnMessageGroup,
Expand Down Expand Up @@ -374,6 +393,15 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
@override
final int serverTypingStartedWaitPeriodMilliseconds;

@override
final List<ThumbnailFormat> serverThumbnailFormats;
@override
List<ThumbnailFormat> get sortedAnimatedThumbnailFormats => _sortedAnimatedThumbnailFormats;
final List<ThumbnailFormat> _sortedAnimatedThumbnailFormats;
@override
List<ThumbnailFormat> get sortedStillThumbnailFormats => _sortedStillThumbnailFormats;
final List<ThumbnailFormat> _sortedStillThumbnailFormats;

@override
final bool realmAllowMessageEditing;
@override
Expand Down Expand Up @@ -432,6 +460,26 @@ class RealmStoreImpl extends HasUserGroupStore with RealmStore {
return displayFields.followedBy(nonDisplayFields).toList();
}

static List<ThumbnailFormat> _filterAndSortThumbnailFormats(
List<ThumbnailFormat> initialServerThumbnailFormats, {
required bool animated,
}) {
return initialServerThumbnailFormats
.where((format) => format.animated == animated)
.toList()
..sort(_compareThumbnailFormats);
}

/// A comparator to sort formats by max resolution, ascending.
///
/// "Max resolution" means
/// [ThumbnailFormat.maxWidth] * [ThumbnailFormat.maxHeight].
static int _compareThumbnailFormats(ThumbnailFormat a, ThumbnailFormat b) {
final aMaxResolution = a.maxWidth * a.maxHeight;
final bMaxResolution = b.maxWidth * b.maxHeight;
return aMaxResolution - bMaxResolution;
}

void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) {
customProfileFields = _sortCustomProfileFields(event.fields);
}
Expand Down
7 changes: 4 additions & 3 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -637,11 +637,12 @@ class MessageImagePreview extends StatelessWidget {

// TODO image hover animation
final srcUrl = node.srcUrl;
final thumbnailUrl = node.thumbnailUrl;
final thumbnailLocator = node.thumbnail;
final store = PerAccountStoreWidget.of(context);
final resolvedSrcUrl = store.tryResolveUrl(srcUrl);
final resolvedThumbnailUrl = thumbnailUrl == null
? null : store.tryResolveUrl(thumbnailUrl);
final resolvedThumbnailUrl = thumbnailLocator?.resolve(context,
width: 150, height: 100,
animationMode: ImageAnimationMode.animateConditionally);

// TODO if src fails to parse, show an explicit "broken image"

Expand Down
Loading